{"repo": "asajid2-cell/codex-local-retrieval", "files": {"native/CodexLocalRetrieval.Server/Program.cs": {"0bd60df9ea622f66f6e6720e6824fe5916840a06": {"t": 1785745810.7709177, "n": 12349, "h": "c0f43870f6f451fb", "c": "using CodexLocalRetrieval.Core.Agents;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent: our server is a client of `codex app-server` (the desktop-app protocol). The hub owns\n// the one app-server and multiplexes it. /api/agent/sessions lists ALL sessions; the WS opens/drives one.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\nvar agentHub = new CodexAgentHub(codexExe);\nvar claudeStore = new ClaudeSessionStore(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_PROJECTS\"));\nvar claudeDriver = new ClaudeLiveDriver(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_EXE\"));\n// Owner-signing for \"auto\" (no-approval) turns: an owner-held key authorizes each auto command (HMAC,\n// fresh, non-replayed). Auto-generated + saved on the PC if unset; the owner reads it off the machine\n// (out-of-band) and enters it in the browser. Never transmitted.\nvar signingKeyFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), \"CodexArchiveRemote\", \"signing.key\");\nvar commandSigner = CommandSigner.LoadOrCreate(Environment.GetEnvironmentVariable(\"CLR_REMOTE_SIGNING_KEY\"), signingKeyFile, m => Console.WriteLine(\"[signing] \" + m));\n\n// One merged, time-sorted list of BOTH tools' sessions: source \"codex\" (drivable) or \"claude\" (history).\napp.MapGet(\"/api/agent/sessions\", async (string? cursor, int? pageSize, CancellationToken ct) =>\n{\n var codex = (await agentHub.ListSessionsAsync(cursor, pageSize ?? 200, ct))\n .Select(c => new AgentSessionDto(c.Id, string.IsNullOrEmpty(c.Name) ? c.Preview : c.Name, c.Preview, c.Cwd, \"codex\", c.UpdatedAt));\n var claude = claudeStore.List(250)\n .Select(c => new AgentSessionDto(c.Id, c.Title, \"\", c.Cwd, \"claude\", c.UpdatedAt));\n return Results.Json(codex.Concat(claude).OrderByDescending(x => x.UpdatedAt).ToList());\n});\n\n// Whether owner-signed \"auto\" mode is available (a signing key is configured on this server).\napp.MapGet(\"/api/agent/config\", () => Results.Json(new { autoAvailable = commandSigner.Enabled }));\n\n// Rename a session. For Claude this appends a custom-title to its rollout (same mechanism + shared with\n// the Claude Code sidebar). Codex titles itself; renaming codex sessions isn't supported here.\napp.MapPost(\"/api/agent/sessions/{id}/rename\", (string id, RenameRequest req) =>\n{\n var title = (req?.Title ?? \"\").Trim();\n if (title.Length == 0) return Results.BadRequest(new { error = \"title required\" });\n if (title.Length > 120) title = title[..120];\n if (req?.Source == \"codex\") return Results.BadRequest(new { error = \"Codex titles its own sessions; rename is available for Claude sessions.\" });\n return claudeStore.RenameSession(id, title)\n ? Results.Json(new { ok = true, title })\n : Results.NotFound(new { error = \"session not found\" });\n});\n\n// Reopen a chat where you work: in VS Code (the same session) or a fresh terminal. Launches on this PC.\nvar claudeExeForLaunch = Environment.GetEnvironmentVariable(\"CLR_CLAUDE_EXE\")\n ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\nvar launcher = new SessionLauncher(claudeExeForLaunch, codexExe, allowLaunch);\napp.MapPost(\"/api/agent/sessions/{id}/open\", (string id, OpenRequest req) =>\n{\n var (ok, msg) = launcher.Open(req?.Source ?? \"codex\", id, req?.Target ?? \"terminal\", req?.Cwd);\n return ok ? Results.Json(new { ok = true, message = msg }) : Results.BadRequest(new { error = msg });\n});\n\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, agentHub, claudeStore, claudeDriver, commandSigner, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "c34ff737bd5660b63fafe5ff6bfbd8699bbbb43c": {"t": 1785756764.2027674, "n": 12349, "h": "c0f43870f6f451fb", "c": "using CodexLocalRetrieval.Core.Agents;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent: our server is a client of `codex app-server` (the desktop-app protocol). The hub owns\n// the one app-server and multiplexes it. /api/agent/sessions lists ALL sessions; the WS opens/drives one.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\nvar agentHub = new CodexAgentHub(codexExe);\nvar claudeStore = new ClaudeSessionStore(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_PROJECTS\"));\nvar claudeDriver = new ClaudeLiveDriver(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_EXE\"));\n// Owner-signing for \"auto\" (no-approval) turns: an owner-held key authorizes each auto command (HMAC,\n// fresh, non-replayed). Auto-generated + saved on the PC if unset; the owner reads it off the machine\n// (out-of-band) and enters it in the browser. Never transmitted.\nvar signingKeyFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), \"CodexArchiveRemote\", \"signing.key\");\nvar commandSigner = CommandSigner.LoadOrCreate(Environment.GetEnvironmentVariable(\"CLR_REMOTE_SIGNING_KEY\"), signingKeyFile, m => Console.WriteLine(\"[signing] \" + m));\n\n// One merged, time-sorted list of BOTH tools' sessions: source \"codex\" (drivable) or \"claude\" (history).\napp.MapGet(\"/api/agent/sessions\", async (string? cursor, int? pageSize, CancellationToken ct) =>\n{\n var codex = (await agentHub.ListSessionsAsync(cursor, pageSize ?? 200, ct))\n .Select(c => new AgentSessionDto(c.Id, string.IsNullOrEmpty(c.Name) ? c.Preview : c.Name, c.Preview, c.Cwd, \"codex\", c.UpdatedAt));\n var claude = claudeStore.List(250)\n .Select(c => new AgentSessionDto(c.Id, c.Title, \"\", c.Cwd, \"claude\", c.UpdatedAt));\n return Results.Json(codex.Concat(claude).OrderByDescending(x => x.UpdatedAt).ToList());\n});\n\n// Whether owner-signed \"auto\" mode is available (a signing key is configured on this server).\napp.MapGet(\"/api/agent/config\", () => Results.Json(new { autoAvailable = commandSigner.Enabled }));\n\n// Rename a session. For Claude this appends a custom-title to its rollout (same mechanism + shared with\n// the Claude Code sidebar). Codex titles itself; renaming codex sessions isn't supported here.\napp.MapPost(\"/api/agent/sessions/{id}/rename\", (string id, RenameRequest req) =>\n{\n var title = (req?.Title ?? \"\").Trim();\n if (title.Length == 0) return Results.BadRequest(new { error = \"title required\" });\n if (title.Length > 120) title = title[..120];\n if (req?.Source == \"codex\") return Results.BadRequest(new { error = \"Codex titles its own sessions; rename is available for Claude sessions.\" });\n return claudeStore.RenameSession(id, title)\n ? Results.Json(new { ok = true, title })\n : Results.NotFound(new { error = \"session not found\" });\n});\n\n// Reopen a chat where you work: in VS Code (the same session) or a fresh terminal. Launches on this PC.\nvar claudeExeForLaunch = Environment.GetEnvironmentVariable(\"CLR_CLAUDE_EXE\")\n ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\nvar launcher = new SessionLauncher(claudeExeForLaunch, codexExe, allowLaunch);\napp.MapPost(\"/api/agent/sessions/{id}/open\", (string id, OpenRequest req) =>\n{\n var (ok, msg) = launcher.Open(req?.Source ?? \"codex\", id, req?.Target ?? \"terminal\", req?.Cwd);\n return ok ? Results.Json(new { ok = true, message = msg }) : Results.BadRequest(new { error = msg });\n});\n\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, agentHub, claudeStore, claudeDriver, commandSigner, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "d3affc263826a822c298e8ecb81ab4b0376a4cc4": {"t": 1785756765.808912, "n": 11679, "h": "934d1de14d66d33e", "c": "using CodexLocalRetrieval.Core.Agents;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent: our server is a client of `codex app-server` (the desktop-app protocol). The hub owns\n// the one app-server and multiplexes it. /api/agent/sessions lists ALL sessions; the WS opens/drives one.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\nvar agentHub = new CodexAgentHub(codexExe);\nvar claudeStore = new ClaudeSessionStore(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_PROJECTS\"));\nvar claudeDriver = new ClaudeLiveDriver(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_EXE\"));\n// Owner-signing for \"auto\" (no-approval) turns: an owner-held key authorizes each auto command (HMAC,\n// fresh, non-replayed). Auto-generated + saved on the PC if unset; the owner reads it off the machine\n// (out-of-band) and enters it in the browser. Never transmitted.\nvar signingKeyFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), \"CodexArchiveRemote\", \"signing.key\");\nvar commandSigner = CommandSigner.LoadOrCreate(Environment.GetEnvironmentVariable(\"CLR_REMOTE_SIGNING_KEY\"), signingKeyFile, m => Console.WriteLine(\"[signing] \" + m));\n\n// One merged, time-sorted list of BOTH tools' sessions: source \"codex\" (drivable) or \"claude\" (history).\napp.MapGet(\"/api/agent/sessions\", async (string? cursor, int? pageSize, CancellationToken ct) =>\n{\n var codex = (await agentHub.ListSessionsAsync(cursor, pageSize ?? 200, ct))\n .Select(c => new AgentSessionDto(c.Id, string.IsNullOrEmpty(c.Name) ? c.Preview : c.Name, c.Preview, c.Cwd, \"codex\", c.UpdatedAt));\n var claude = claudeStore.List(250)\n .Select(c => new AgentSessionDto(c.Id, c.Title, \"\", c.Cwd, \"claude\", c.UpdatedAt));\n return Results.Json(codex.Concat(claude).OrderByDescending(x => x.UpdatedAt).ToList());\n});\n\n// Whether owner-signed \"auto\" mode is available (a signing key is configured on this server).\napp.MapGet(\"/api/agent/config\", () => Results.Json(new { autoAvailable = commandSigner.Enabled }));\n\n// Rename a session. For Claude this appends a custom-title to its rollout (same mechanism + shared with\n// the Claude Code sidebar). Codex titles itself; renaming codex sessions isn't supported here.\napp.MapPost(\"/api/agent/sessions/{id}/rename\", (string id, RenameRequest req) =>\n{\n var title = (req?.Title ?? \"\").Trim();\n if (title.Length == 0) return Results.BadRequest(new { error = \"title required\" });\n if (title.Length > 120) title = title[..120];\n if (req?.Source == \"codex\") return Results.BadRequest(new { error = \"Codex titles its own sessions; rename is available for Claude sessions.\" });\n return claudeStore.RenameSession(id, title)\n ? Results.Json(new { ok = true, title })\n : Results.NotFound(new { error = \"session not found\" });\n});\n\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, agentHub, claudeStore, claudeDriver, commandSigner, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "2d566c03d22819640475a6c33ff0d5d2756e30af": {"t": 1785756767.421076, "n": 10897, "h": "5ddf04741bcc3555", "c": "using CodexLocalRetrieval.Core.Agents;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent: our server is a client of `codex app-server` (the desktop-app protocol). The hub owns\n// the one app-server and multiplexes it. /api/agent/sessions lists ALL sessions; the WS opens/drives one.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\nvar agentHub = new CodexAgentHub(codexExe);\nvar claudeStore = new ClaudeSessionStore(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_PROJECTS\"));\nvar claudeDriver = new ClaudeLiveDriver(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_EXE\"));\n// Owner-signing for \"auto\" (no-approval) turns: an owner-held key authorizes each auto command (HMAC,\n// fresh, non-replayed). Auto-generated + saved on the PC if unset; the owner reads it off the machine\n// (out-of-band) and enters it in the browser. Never transmitted.\nvar signingKeyFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), \"CodexArchiveRemote\", \"signing.key\");\nvar commandSigner = CommandSigner.LoadOrCreate(Environment.GetEnvironmentVariable(\"CLR_REMOTE_SIGNING_KEY\"), signingKeyFile, m => Console.WriteLine(\"[signing] \" + m));\n\n// One merged, time-sorted list of BOTH tools' sessions: source \"codex\" (drivable) or \"claude\" (history).\napp.MapGet(\"/api/agent/sessions\", async (string? cursor, int? pageSize, CancellationToken ct) =>\n{\n var codex = (await agentHub.ListSessionsAsync(cursor, pageSize ?? 200, ct))\n .Select(c => new AgentSessionDto(c.Id, string.IsNullOrEmpty(c.Name) ? c.Preview : c.Name, c.Preview, c.Cwd, \"codex\", c.UpdatedAt));\n var claude = claudeStore.List(250)\n .Select(c => new AgentSessionDto(c.Id, c.Title, \"\", c.Cwd, \"claude\", c.UpdatedAt));\n return Results.Json(codex.Concat(claude).OrderByDescending(x => x.UpdatedAt).ToList());\n});\n\n// Whether owner-signed \"auto\" mode is available (a signing key is configured on this server).\napp.MapGet(\"/api/agent/config\", () => Results.Json(new { autoAvailable = commandSigner.Enabled }));\n\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, agentHub, claudeStore, claudeDriver, commandSigner, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "e5ca3a8a49d9cf932b71536762263b9a5ae3680d": {"t": 1785756768.780203, "n": 10099, "h": "f5dfd8cd00c0426e", "c": "using CodexLocalRetrieval.Core.Agents;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent: our server is a client of `codex app-server` (the desktop-app protocol). The hub owns\n// the one app-server and multiplexes it. /api/agent/sessions lists ALL sessions; the WS opens/drives one.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\nvar agentHub = new CodexAgentHub(codexExe);\nvar claudeStore = new ClaudeSessionStore(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_PROJECTS\"));\nvar claudeDriver = new ClaudeLiveDriver(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_EXE\"));\n\n// One merged, time-sorted list of BOTH tools' sessions: source \"codex\" (drivable) or \"claude\" (history).\napp.MapGet(\"/api/agent/sessions\", async (string? cursor, int? pageSize, CancellationToken ct) =>\n{\n var codex = (await agentHub.ListSessionsAsync(cursor, pageSize ?? 200, ct))\n .Select(c => new AgentSessionDto(c.Id, string.IsNullOrEmpty(c.Name) ? c.Preview : c.Name, c.Preview, c.Cwd, \"codex\", c.UpdatedAt));\n var claude = claudeStore.List(250)\n .Select(c => new AgentSessionDto(c.Id, c.Title, \"\", c.Cwd, \"claude\", c.UpdatedAt));\n return Results.Json(codex.Concat(claude).OrderByDescending(x => x.UpdatedAt).ToList());\n});\n\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, agentHub, claudeStore, claudeDriver, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "8dd79385ffe324e7edbb8881b01622d7e892ae39": {"t": 1785756770.1568801, "n": 10098, "h": "5b3ae5fd6c1992f4", "c": "using CodexLocalRetrieval.Core.Agents;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent: our server is a client of `codex app-server` (the desktop-app protocol). The hub owns\n// the one app-server and multiplexes it. /api/agent/sessions lists ALL sessions; the WS opens/drives one.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\nvar agentHub = new CodexAgentHub(codexExe);\nvar claudeStore = new ClaudeSessionStore(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_PROJECTS\"));\nvar claudeDriver = new ClaudeLiveDriver(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_EXE\"));\n\n// One merged, time-sorted list of BOTH tools' sessions: source \"codex\" (drivable) or \"claude\" (history).\napp.MapGet(\"/api/agent/sessions\", async (string? cursor, int? pageSize, CancellationToken ct) =>\n{\n var codex = (await agentHub.ListSessionsAsync(cursor, pageSize ?? 60, ct))\n .Select(c => new AgentSessionDto(c.Id, string.IsNullOrEmpty(c.Name) ? c.Preview : c.Name, c.Preview, c.Cwd, \"codex\", c.UpdatedAt));\n var claude = claudeStore.List(120)\n .Select(c => new AgentSessionDto(c.Id, c.Title, \"\", c.Cwd, \"claude\", c.UpdatedAt));\n return Results.Json(codex.Concat(claude).OrderByDescending(x => x.UpdatedAt).ToList());\n});\n\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, agentHub, claudeStore, claudeDriver, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "906dc39de913e2ba44e26df0d095b82886a4b9b0": {"t": 1785756771.931932, "n": 9989, "h": "d745e1c465c07b90", "c": "using CodexLocalRetrieval.Core.Agents;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent: our server is a client of `codex app-server` (the desktop-app protocol). The hub owns\n// the one app-server and multiplexes it. /api/agent/sessions lists ALL sessions; the WS opens/drives one.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\nvar agentHub = new CodexAgentHub(codexExe);\nvar claudeStore = new ClaudeSessionStore(Environment.GetEnvironmentVariable(\"CLR_CLAUDE_PROJECTS\"));\n\n// One merged, time-sorted list of BOTH tools' sessions: source \"codex\" (drivable) or \"claude\" (history).\napp.MapGet(\"/api/agent/sessions\", async (string? cursor, int? pageSize, CancellationToken ct) =>\n{\n var codex = (await agentHub.ListSessionsAsync(cursor, pageSize ?? 60, ct))\n .Select(c => new AgentSessionDto(c.Id, string.IsNullOrEmpty(c.Name) ? c.Preview : c.Name, c.Preview, c.Cwd, \"codex\", c.UpdatedAt));\n var claude = claudeStore.List(120)\n .Select(c => new AgentSessionDto(c.Id, c.Title, \"\", c.Cwd, \"claude\", c.UpdatedAt));\n return Results.Json(codex.Concat(claude).OrderByDescending(x => x.UpdatedAt).ToList());\n});\n\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, agentHub, claudeStore, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "d07c6af1bb298de09576f7e39963d0751c3d6d68": {"t": 1785756773.5902839, "n": 9363, "h": "6b8016b245f6a9c9", "c": "using CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent: our server is a client of `codex app-server` (the desktop-app protocol). The hub owns\n// the one app-server and multiplexes it. /api/agent/sessions lists ALL sessions; the WS opens/drives one.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\nvar agentHub = new CodexAgentHub(codexExe);\n\napp.MapGet(\"/api/agent/sessions\", async (string? cursor, int? pageSize, CancellationToken ct) =>\n Results.Json(await agentHub.ListSessionsAsync(cursor, pageSize ?? 60, ct)));\n\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, agentHub, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "63043cfe40bfc758d1e7fbe5a0134294d3f6f311": {"t": 1785756775.984379, "n": 9042, "h": "f8b981c400b5490d", "c": "using CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\nusing CodexLocalRetrieval.Server;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\napp.UseWebSockets(); // live agent sessions stream over /api/agent\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n // Browsers can't set an Authorization header on a WebSocket, so the WS upgrade may carry\n // the token as ?token= instead. (hl-auth mode uses the cookie and never hits this branch.)\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"])\n ?? (ctx.WebSockets.IsWebSocketRequest ? ctx.Request.Query[\"token\"].ToString() : null);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\n// Live agent session over a WebSocket: drive codex in a workspace, stream reasoning/commands/output.\nvar codexExe = Environment.GetEnvironmentVariable(\"CLR_CODEX_EXE\") ?? ArchiveService.ResolveCodexExe();\nvar defaultWs = Environment.GetEnvironmentVariable(\"CLR_AGENT_DEFAULT_WS\") ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\napp.Map(\"/api/agent\", async (HttpContext ctx) =>\n{\n if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }\n using var sock = await ctx.WebSockets.AcceptWebSocketAsync();\n await AgentWebSocket.HandleAsync(sock, archive, codexExe, defaultWs, ctx.RequestAborted);\n});\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "c9db192ab1d7accd425d5247c1f15a0ad6c77835": {"t": 1785756778.9565892, "n": 7939, "h": "a663627abf14e80d", "c": "using CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\")?.Trim();\n\n// hl-auth (SSO) mode: gate by the harmonizerlabs.cc account system instead of the bearer token.\nvar hlAuthOn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH\") == \"1\";\nvar hlBase = (Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_BASE\") ?? \"\").TrimEnd('/');\nvar hlPage = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_PAGE\"); // page id to require (empty = any signed-in account)\nvar hlCookie = Environment.GetEnvironmentVariable(\"CLR_REMOTE_HLAUTH_COOKIE\") ?? \"hl_session\";\nvar hlReturn = Environment.GetEnvironmentVariable(\"CLR_REMOTE_PUBLIC_PATH\") ?? \"/\"; // where /auth/login sends you back\n\nif (hlAuthOn)\n{\n if (string.IsNullOrWhiteSpace(hlBase))\n {\n Console.Error.WriteLine(\"refusing to start: CLR_REMOTE_HLAUTH=1 requires CLR_REMOTE_HLAUTH_BASE (e.g. https://harmonizerlabs.cc).\");\n return 1;\n }\n}\nelse if (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars (or enable CLR_REMOTE_HLAUTH).\");\n return 1;\n}\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\nif (hlAuthOn)\n{\n // hl-auth SSO gate: every request (except /healthz) must carry an hl_session cookie that the\n // hl-auth account system says can open this page. Not signed in -> bounce to the hl-auth login\n // page; signed in but not allowed -> 403. Decision cached ~30s per cookie. Fails closed.\n var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };\n var gate = new HlAuthGate(async (cookieVal, ct) =>\n {\n using var req = new HttpRequestMessage(HttpMethod.Get, $\"{hlBase}/auth/api/access\");\n req.Headers.TryAddWithoutValidation(\"Cookie\", $\"{hlCookie}={cookieVal}\");\n using var resp = await http.SendAsync(req, ct);\n return resp.IsSuccessStatusCode ? await resp.Content.ReadAsStringAsync(ct) : null;\n }, hlPage);\n var cache = new System.Collections.Concurrent.ConcurrentDictionary();\n\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/healthz\")) { await next(); return; }\n var cookie = ctx.Request.Cookies[hlCookie];\n GateOutcome outcome;\n if (string.IsNullOrEmpty(cookie)) outcome = GateOutcome.Login;\n else if (cache.TryGetValue(cookie, out var hit) && hit.exp > DateTime.UtcNow) outcome = hit.outcome;\n else { outcome = await gate.CheckAsync(cookie, ctx.RequestAborted); cache[cookie] = (DateTime.UtcNow.AddSeconds(30), outcome); }\n\n if (outcome == GateOutcome.Allow) { await next(); return; }\n if (outcome == GateOutcome.Login)\n {\n ctx.Response.Redirect($\"{hlBase}/auth/login?next={Uri.EscapeDataString(hlReturn)}\");\n return;\n }\n ctx.Response.StatusCode = StatusCodes.Status403Forbidden;\n await ctx.Response.WriteAsync(\"Forbidden — your account isn't allowed to open this page.\");\n });\n}\nelse\n{\n // Bearer-token gate on everything under /api. The static SPA shell (/, /index.html) is open — it\n // carries no data and asks for the token itself, then sends it as a Bearer header on API calls.\n app.Use(async (ctx, next) =>\n {\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"]);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n });\n}\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\nvar authMode = hlAuthOn ? $\"hl-auth ({hlBase}, page:{hlPage ?? \"any\"})\" : \"bearer token\";\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, auth: {authMode}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}, "f156a67bb13a5c307e88c7c1a8a2a75cf7d8111b": {"t": 1785756781.259446, "n": 5026, "h": "ea9feda215318ef2", "c": "using CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Remote;\nusing CodexLocalRetrieval.Core.Services;\n\n// ---- config (env only; nothing secret is ever read from disk or args) ----\nvar token = Environment.GetEnvironmentVariable(\"CLR_REMOTE_TOKEN\");\nif (!RemoteAuth.IsValidConfiguredToken(token))\n{\n Console.Error.WriteLine($\"refusing to start: set CLR_REMOTE_TOKEN to a secret of at least {RemoteAuth.MinTokenLength} chars.\");\n return 1;\n}\ntoken = token!.Trim();\n\nvar port = int.TryParse(Environment.GetEnvironmentVariable(\"CLR_REMOTE_PORT\"), out var p) ? p : 8765;\nvar bind = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BIND\") ?? \"127.0.0.1\"; // localhost only; nginx is the edge\nvar redactReads = Environment.GetEnvironmentVariable(\"CLR_REMOTE_REDACT_READS\") == \"1\";\nvar allowLaunch = Environment.GetEnvironmentVariable(\"CLR_REMOTE_ALLOW_LAUNCH\") == \"1\";\n\n// ---- archive: load the store and index the live session folders so remote browsing is current ----\n// CLR_REMOTE_STORE: point at a specific app-store.json (e.g. a synced copy). CLR_REMOTE_BUNDLED=1\n// uses the repo's sanitized demo store (handy for a smoke test without touching real chats).\nvar storePath = Environment.GetEnvironmentVariable(\"CLR_REMOTE_STORE\");\nvar useBundled = Environment.GetEnvironmentVariable(\"CLR_REMOTE_BUNDLED\") == \"1\";\nvar archive = new ArchiveService(storePath: string.IsNullOrWhiteSpace(storePath) ? null : storePath, useBundledStore: useBundled);\nawait archive.LoadAsync();\nif (Environment.GetEnvironmentVariable(\"CLR_REMOTE_SYNC\") != \"0\")\n{\n try { await archive.SyncFromDiskAsync(); } catch (Exception ex) { Console.Error.WriteLine(\"sync warning: \" + ex.Message); }\n}\n\nIChatBackend? BackendFactory()\n{\n var provider = archive.ActiveAiProvider()\n ?? archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\")\n ?? Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n return string.IsNullOrWhiteSpace(key) ? null : new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n}\n\nvar api = new RemoteApi(archive, BackendFactory, redactReads, allowLaunch);\n\n// Resolve wwwroot robustly: next to the binary when published, else the project source when running\n// from bin during development. Content root = binary dir so this works regardless of launch cwd.\nvar contentRoot = AppContext.BaseDirectory;\nvar webRoot = Path.Combine(contentRoot, \"wwwroot\");\nif (!Directory.Exists(webRoot))\n{\n var srcWwwroot = Path.GetFullPath(Path.Combine(contentRoot, \"..\", \"..\", \"..\", \"wwwroot\"));\n if (Directory.Exists(srcWwwroot)) webRoot = srcWwwroot;\n}\nvar builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = args, ContentRootPath = contentRoot, WebRootPath = webRoot });\nbuilder.WebHost.UseUrls($\"http://{bind}:{port}\");\nbuilder.Logging.AddSimpleConsole(o => o.SingleLine = true);\nvar app = builder.Build();\n\n// Token gate on everything under /api. The static SPA shell (/, /index.html) is open — it carries no\n// data and asks for the token itself, then sends it as a Bearer header on API calls.\napp.Use(async (ctx, next) =>\n{\n if (ctx.Request.Path.StartsWithSegments(\"/api\"))\n {\n var presented = RemoteAuth.Extract(ctx.Request.Headers.Authorization, ctx.Request.Headers[\"X-Auth-Token\"]);\n if (!RemoteAuth.Matches(presented, token))\n {\n ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;\n await ctx.Response.WriteAsJsonAsync(new { error = \"unauthorized\" });\n return;\n }\n }\n await next();\n});\n\napp.UseDefaultFiles();\napp.UseStaticFiles();\n\napp.MapGet(\"/healthz\", () => Results.Ok(new { ok = true, service = \"codex-local-retrieval\", chats = archive.Store.Sessions.Count }));\napp.MapGet(\"/api/stats\", () => Results.Json(api.Stats()));\napp.MapGet(\"/api/chats\", (string? q, int? limit) => Results.Json(api.Search(q, limit ?? 20)));\napp.MapGet(\"/api/chats/{id}\", (string id, int? page, int? pageSize) =>\n api.Read(id, page ?? 0, pageSize ?? 20) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapGet(\"/api/chats/{id}/events\", (string id, int? limit) =>\n api.Events(id, limit ?? 400) is { } r ? Results.Json(r) : Results.NotFound(new { error = \"no chat with that id\" }));\napp.MapPost(\"/api/copilot\", async (CopilotRequest req, CancellationToken ct) => Results.Json(await api.CopilotAsync(req.Message, req.History, ct)));\napp.MapPost(\"/api/chats/{id}/resume\", (string id, ResumeRequest? req) => Results.Json(api.ResumeCommand(id, req?.Launch ?? false)));\napp.MapPost(\"/api/chats/{id}/favorite\", async (string id, FavoriteRequest? req) => Results.Json(await api.FavoriteAsync(id, req?.Favorite ?? true)));\n\nConsole.WriteLine($\"codex-local-retrieval remote server on http://{bind}:{port} (chats: {archive.Store.Sessions.Count}, launch: {(allowLaunch ? \"on\" : \"off\")}, redact-reads: {(redactReads ? \"on\" : \"off\")})\");\napp.Run();\nreturn 0;\n"}}, "native/CodexLocalRetrieval.Native/MainPage.xaml.cs": {"0bd60df9ea622f66f6e6720e6824fe5916840a06": {"t": 1785745820.0587134, "n": 94405, "h": "02266d94506117b0", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n // Responsive: below this width the right rail + chat reader can't both fit, so the right\n // panel (secondary actions, all reachable from the header + ... menu) folds away.\n SizeChanged += (_, _) =>\n {\n var narrow = ActualWidth > 0 && ActualWidth < RightPanelMinWidth;\n if (narrow == _narrowLayout) return;\n _narrowLayout = narrow;\n UpdateChrome();\n };\n }\n\n private const double RightPanelMinWidth = 1120;\n private bool _narrowLayout;\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplyFilters();\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n ApplyFilters();\n }\n }\n\n // Kept for callers that pre-set SearchBox.Text (e.g. capture replay); routes through the unified\n // text + tag filter so an active tag filter is always respected.\n private void ApplySearch(string query) => ApplyFilters();\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n case \"Brain\":\n RenderBrainPage();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n bool showRight = sessionContext && !_narrowLayout; // fold the right rail when too narrow to fit\n RightColumnBorder.Visibility = showRight ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = showRight ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n // Header overflow menu: the same secondary actions as the right \"Quick actions\" rail, reachable\n // at ALL widths - critical once the right rail folds away on a narrow window.\n private void HeaderMore_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is null) return;\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false, Placement = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedRight };\n void Add(string text, Action act) { var item = new MenuFlyoutItem { Text = text }; item.Click += (_, _) => act(); flyout.Items.Add(item); }\n Add(\"Add to project\", () => ShowAddToProjectFlyout(HeaderMoreButton, _selected!));\n Add(\"Bump to top of resume list\", () => _ = BumpSession(_selected!));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Copy resume prompt\", () => Copy(\"resume\"));\n Add(\"Copy chat path\", () => Copy(\"path\"));\n Add(\"Copy all code\", () => Copy(\"code\"));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Build restore packet\", () => Navigate(\"Restore\"));\n Add(\"Inspect raw events\", () => Navigate(\"Source\"));\n flyout.ShowAt(HeaderMoreButton);\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n private ArchiveSession? _contentLoadingSession;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n if (!ReferenceEquals(_contentLoadingSession, _selected))\n {\n _contentLoadingSession = _selected;\n _ = EnsureContentThenRenderAsync(_selected);\n }\n return;\n }\n\n var messages = _selected.Messages;\n if (messages.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No conversation messages parsed\", _selected.SourcePath));\n return;\n }\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n finally\n {\n if (ReferenceEquals(_contentLoadingSession, session)) _contentLoadingSession = null;\n }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt),\n pathLabel: group.First().Workspace));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeckPickerBar());\n MainContent.Children.Add(CollectionsControlBar());\n\n var pills = CollectionFilterPills();\n if (pills is not null) MainContent.Children.Add(pills);\n\n // Only the active deck's collections (scoped), then the compound tag filter.\n var activeDeck = _archive.ActiveDeckId;\n var collections = FilteredCollections().Where(c => string.Equals(ArchiveService.CollectionDeck(c), activeDeck, StringComparison.OrdinalIgnoreCase)).ToList();\n if (collections.Count == 0)\n {\n var filtering = AnyCollectionFilterActive();\n MainContent.Children.Add(EmptyBlock(\n filtering ? \"No projects match this filter\" : \"No collections yet\",\n filtering\n ? \"Clear the filter (top-right funnel), or tag a collection with the selected tags.\"\n : \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = CollectionChatsFiltered(collection); // chat-tag filter + layer sort\n var id = collection.Id;\n var name = collection.Name;\n // Count only chats that actually resolve to a stored session (stale ids are ignored),\n // and only show \"N of M\" when a chat-tag filter is hiding some.\n var resolvedTotal = collection.SessionIds.Count(sid => _archive.Store.Sessions.ContainsKey(sid));\n var chatFilterActive = _collChatInclude.Count > 0 || _collChatExclude.Count > 0;\n var subtitle = chatFilterActive ? $\"{sessions.Count} of {resolvedTotal} chats\" : $\"{sessions.Count} chats\";\n var deckName = _archive.Decks.FirstOrDefault(d => string.Equals(d.Id, ArchiveService.CollectionDeck(collection), StringComparison.OrdinalIgnoreCase))?.Name ?? \"Main\";\n MainContent.Children.Add(ExpandableSessionGroup(name, subtitle, sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name, deckName),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id),\n tags: collection.Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),\n onAddTag: () => _ = ShowAddCollectionTagDialogAsync(id, name),\n onRemoveTag: t => _ = RemoveCollectionTagAndRefreshAsync(id, t),\n onMoveSession: (s, delta, toEnd) => _ = ReorderInCollectionAndRefreshAsync(id, s.Id, delta, toEnd),\n moveTargets: _archive.Decks.Where(d => !string.Equals(d.Id, ArchiveService.CollectionDeck(collection), StringComparison.OrdinalIgnoreCase)).Select(d => (d.Id, d.Name)).ToList(),\n onMoveToDeck: deckId => _ = MoveCollectionToDeckAndRefreshAsync(id, deckId)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n // Project Brain entry point — opens the per-collection memory screen.\n var brainButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Brain\" };\n ToolTipService.SetToolTip(brainButton, \"Build a durable, source-linked memory brain for a project\");\n brainButton.Click += (_, _) => Navigate(\"Brain\");\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var filterButton = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 40,\n Height = 40,\n MinWidth = 40,\n MinHeight = 40,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(filterButton, \"Filter projects by tag, and chats within them\");\n filterButton.Click += CollectionFilter_Click;\n\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(filterButton);\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(brainButton);\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name, string deckName)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name, deckName));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" (deck: {deckName}) - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName, string deckName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\" on deck \\\"{deckName}\\\".\\n\" +\n \"If I gave this chat a name (e.g. \\\"add yourself as codex-claude-local\\\"), set that as its \" +\n \"in-app name too. The name is app-only - it does NOT change your global/native title.\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, requestId, and optional name in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"deck\\\":\\\"{deckName}\\\",\\\"name\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, files it into the project, sets the \" +\n \"optional in-app name, and acks with the same requestId plus resolvedSessionId and persisted. \" +\n \"If the id cannot be resolved it returns an error rather than adding a different chat. Only if \" +\n \"your runtime truly has no session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \" +\n \"\\\"cwd\\\" and \\\"tool\\\". (To only rename, omit project and use \\\"op\\\":\\\"setName\\\".) \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null,\n IReadOnlyList? tags = null, Action? onAddTag = null, Action? onRemoveTag = null,\n string? pathLabel = null, Action? onMoveSession = null,\n IReadOnlyList<(string Id, string Name)>? moveTargets = null, Action? onMoveToDeck = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session,\n onRemoveSession is null ? null : () => onRemoveSession(session),\n onMoveSession is null ? null : (delta, toEnd) => onMoveSession(session, delta, toEnd)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleArea = new StackPanel { Spacing = 6, VerticalAlignment = VerticalAlignment.Center };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n titleArea.Children.Add(titleRow);\n if (!string.IsNullOrWhiteSpace(pathLabel))\n titleArea.Children.Add(new TextBlock { Text = pathLabel, Foreground = MutedBrush(), FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, TextWrapping = TextWrapping.NoWrap });\n if (onAddTag is not null) titleArea.Children.Add(CollectionTagsRow(tags ?? Array.Empty(), onAddTag, onRemoveTag));\n header.Children.Add(titleArea);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onMoveToDeck is not null && moveTargets is { Count: > 0 })\n {\n var moveBtn = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" } // Move to folder\n };\n ToolTipService.SetToolTip(moveBtn, \"Move this project to another deck\");\n var moveFlyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n foreach (var (tid, tname) in moveTargets)\n {\n var id = tid;\n var item = new MenuFlyoutItem { Text = \"Move to \" + tname };\n item.Click += (_, _) => onMoveToDeck(id);\n moveFlyout.Items.Add(item);\n }\n moveBtn.Flyout = moveFlyout;\n rightActions.Children.Add(moveBtn);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null, Action? onMove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n var dots = TagDots(session);\n if (dots is not null) meta.Children.Add(dots);\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove, onMove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n // onMove (collections only): (delta, toEnd) manual reorder within the collection.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove, Action? onMove = null)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n var bump = new MenuFlyoutItem\n {\n Text = \"Bump to top of resume list\",\n };\n ToolTipService.SetToolTip(bump, BumpTooltip(session));\n bump.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bump);\n\n if (onMove is not null)\n {\n var move = new MenuFlyoutSubItem { Text = \"Move in collection\" };\n void MoveItem(string text, int delta, bool toEnd) { var it = new MenuFlyoutItem { Text = text }; it.Click += (_, _) => onMove(delta, toEnd); move.Items.Add(it); }\n MoveItem(\"Move to top\", -1, true);\n MoveItem(\"Move up\", -1, false);\n MoveItem(\"Move down\", +1, false);\n MoveItem(\"Move to bottom\", +1, true);\n flyout.Items.Add(move);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n // Add to collection (multi-membership: a chat can live in several collections at once).\n var addToCol = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var col in _archive.Store.Collections.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))\n {\n var name = col.Name;\n var already = col.SessionIds.Contains(session.Id);\n var ci = new MenuFlyoutItem { Text = already ? \"✓ \" + name : name, IsEnabled = !already };\n ci.Click += async (_, _) => { await _archive.AddToCollectionAsync(session, name); SyncStatus.Text = $\"Added to \\\"{name}\\\".\"; RenderCurrent(); };\n addToCol.Items.Add(ci);\n }\n if (addToCol.Items.Count > 0) addToCol.Items.Add(new MenuFlyoutSeparator());\n var newCol = new MenuFlyoutItem { Text = \"New collection...\" };\n newCol.Click += async (_, _) => await AddSessionToNewCollectionAsync(session);\n addToCol.Items.Add(newCol);\n flyout.Items.Add(addToCol);\n\n // Tags: toggle the chat's current tags off, or add a new one - taggable from inside a collection.\n var tagsSub = new MenuFlyoutSubItem { Text = \"Tags\" };\n var userTags = ArchiveService.UserTags(session);\n foreach (var t in userTags)\n {\n var tag = t;\n var ti = new ToggleMenuFlyoutItem { Text = tag, IsChecked = true };\n ti.Click += async (_, _) => { await _archive.RemoveChatTagAsync(session, tag); RenderCurrent(); };\n tagsSub.Items.Add(ti);\n }\n if (userTags.Count > 0) tagsSub.Items.Add(new MenuFlyoutSeparator());\n var addTag = new MenuFlyoutItem { Text = \"Add tag...\" };\n addTag.Click += async (_, _) => { await ShowAddTagDialogAsync(session); RenderCurrent(); };\n tagsSub.Items.Add(addTag);\n flyout.Items.Add(tagsSub);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n // Compact: hide the bulky RGB/HSV text inputs (the \"More\" toggle clipped to \"Mo\"), keeping the\n // spectrum + sliders + hex so the picker stays short and nothing clips in the Settings viewport.\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false,\n IsColorChannelTextInputVisible = false,\n IsHexInputVisible = true\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var bumpItem = new MenuFlyoutItem { Text = \"Bump to top of resume list\" };\n ToolTipService.SetToolTip(bumpItem, BumpTooltip(session));\n bumpItem.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bumpItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n\n private static Windows.UI.Color ColorFromHex(string hex)\n {\n try\n {\n var h = hex.TrimStart('#');\n return Windows.UI.Color.FromArgb(255,\n Convert.ToByte(h.Substring(0, 2), 16),\n Convert.ToByte(h.Substring(2, 2), 16),\n Convert.ToByte(h.Substring(4, 2), 16));\n }\n catch { return Windows.UI.Color.FromArgb(255, 0x60, 0xA5, 0xFA); }\n }\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush RaisedBrush() => (SolidColorBrush)Application.Current.Resources[\"RaisedBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "fc9ea666d249995cd84b5ec978a2493819fee5cd": {"t": 1785756764.8301947, "n": 94405, "h": "02266d94506117b0", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n // Responsive: below this width the right rail + chat reader can't both fit, so the right\n // panel (secondary actions, all reachable from the header + ... menu) folds away.\n SizeChanged += (_, _) =>\n {\n var narrow = ActualWidth > 0 && ActualWidth < RightPanelMinWidth;\n if (narrow == _narrowLayout) return;\n _narrowLayout = narrow;\n UpdateChrome();\n };\n }\n\n private const double RightPanelMinWidth = 1120;\n private bool _narrowLayout;\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplyFilters();\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n ApplyFilters();\n }\n }\n\n // Kept for callers that pre-set SearchBox.Text (e.g. capture replay); routes through the unified\n // text + tag filter so an active tag filter is always respected.\n private void ApplySearch(string query) => ApplyFilters();\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n case \"Brain\":\n RenderBrainPage();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n bool showRight = sessionContext && !_narrowLayout; // fold the right rail when too narrow to fit\n RightColumnBorder.Visibility = showRight ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = showRight ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n // Header overflow menu: the same secondary actions as the right \"Quick actions\" rail, reachable\n // at ALL widths - critical once the right rail folds away on a narrow window.\n private void HeaderMore_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is null) return;\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false, Placement = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedRight };\n void Add(string text, Action act) { var item = new MenuFlyoutItem { Text = text }; item.Click += (_, _) => act(); flyout.Items.Add(item); }\n Add(\"Add to project\", () => ShowAddToProjectFlyout(HeaderMoreButton, _selected!));\n Add(\"Bump to top of resume list\", () => _ = BumpSession(_selected!));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Copy resume prompt\", () => Copy(\"resume\"));\n Add(\"Copy chat path\", () => Copy(\"path\"));\n Add(\"Copy all code\", () => Copy(\"code\"));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Build restore packet\", () => Navigate(\"Restore\"));\n Add(\"Inspect raw events\", () => Navigate(\"Source\"));\n flyout.ShowAt(HeaderMoreButton);\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n private ArchiveSession? _contentLoadingSession;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n if (!ReferenceEquals(_contentLoadingSession, _selected))\n {\n _contentLoadingSession = _selected;\n _ = EnsureContentThenRenderAsync(_selected);\n }\n return;\n }\n\n var messages = _selected.Messages;\n if (messages.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No conversation messages parsed\", _selected.SourcePath));\n return;\n }\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n finally\n {\n if (ReferenceEquals(_contentLoadingSession, session)) _contentLoadingSession = null;\n }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt),\n pathLabel: group.First().Workspace));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeckPickerBar());\n MainContent.Children.Add(CollectionsControlBar());\n\n var pills = CollectionFilterPills();\n if (pills is not null) MainContent.Children.Add(pills);\n\n // Only the active deck's collections (scoped), then the compound tag filter.\n var activeDeck = _archive.ActiveDeckId;\n var collections = FilteredCollections().Where(c => string.Equals(ArchiveService.CollectionDeck(c), activeDeck, StringComparison.OrdinalIgnoreCase)).ToList();\n if (collections.Count == 0)\n {\n var filtering = AnyCollectionFilterActive();\n MainContent.Children.Add(EmptyBlock(\n filtering ? \"No projects match this filter\" : \"No collections yet\",\n filtering\n ? \"Clear the filter (top-right funnel), or tag a collection with the selected tags.\"\n : \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = CollectionChatsFiltered(collection); // chat-tag filter + layer sort\n var id = collection.Id;\n var name = collection.Name;\n // Count only chats that actually resolve to a stored session (stale ids are ignored),\n // and only show \"N of M\" when a chat-tag filter is hiding some.\n var resolvedTotal = collection.SessionIds.Count(sid => _archive.Store.Sessions.ContainsKey(sid));\n var chatFilterActive = _collChatInclude.Count > 0 || _collChatExclude.Count > 0;\n var subtitle = chatFilterActive ? $\"{sessions.Count} of {resolvedTotal} chats\" : $\"{sessions.Count} chats\";\n var deckName = _archive.Decks.FirstOrDefault(d => string.Equals(d.Id, ArchiveService.CollectionDeck(collection), StringComparison.OrdinalIgnoreCase))?.Name ?? \"Main\";\n MainContent.Children.Add(ExpandableSessionGroup(name, subtitle, sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name, deckName),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id),\n tags: collection.Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),\n onAddTag: () => _ = ShowAddCollectionTagDialogAsync(id, name),\n onRemoveTag: t => _ = RemoveCollectionTagAndRefreshAsync(id, t),\n onMoveSession: (s, delta, toEnd) => _ = ReorderInCollectionAndRefreshAsync(id, s.Id, delta, toEnd),\n moveTargets: _archive.Decks.Where(d => !string.Equals(d.Id, ArchiveService.CollectionDeck(collection), StringComparison.OrdinalIgnoreCase)).Select(d => (d.Id, d.Name)).ToList(),\n onMoveToDeck: deckId => _ = MoveCollectionToDeckAndRefreshAsync(id, deckId)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n // Project Brain entry point — opens the per-collection memory screen.\n var brainButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Brain\" };\n ToolTipService.SetToolTip(brainButton, \"Build a durable, source-linked memory brain for a project\");\n brainButton.Click += (_, _) => Navigate(\"Brain\");\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var filterButton = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 40,\n Height = 40,\n MinWidth = 40,\n MinHeight = 40,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(filterButton, \"Filter projects by tag, and chats within them\");\n filterButton.Click += CollectionFilter_Click;\n\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(filterButton);\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(brainButton);\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name, string deckName)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name, deckName));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" (deck: {deckName}) - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName, string deckName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\" on deck \\\"{deckName}\\\".\\n\" +\n \"If I gave this chat a name (e.g. \\\"add yourself as codex-claude-local\\\"), set that as its \" +\n \"in-app name too. The name is app-only - it does NOT change your global/native title.\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, requestId, and optional name in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"deck\\\":\\\"{deckName}\\\",\\\"name\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, files it into the project, sets the \" +\n \"optional in-app name, and acks with the same requestId plus resolvedSessionId and persisted. \" +\n \"If the id cannot be resolved it returns an error rather than adding a different chat. Only if \" +\n \"your runtime truly has no session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \" +\n \"\\\"cwd\\\" and \\\"tool\\\". (To only rename, omit project and use \\\"op\\\":\\\"setName\\\".) \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null,\n IReadOnlyList? tags = null, Action? onAddTag = null, Action? onRemoveTag = null,\n string? pathLabel = null, Action? onMoveSession = null,\n IReadOnlyList<(string Id, string Name)>? moveTargets = null, Action? onMoveToDeck = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session,\n onRemoveSession is null ? null : () => onRemoveSession(session),\n onMoveSession is null ? null : (delta, toEnd) => onMoveSession(session, delta, toEnd)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleArea = new StackPanel { Spacing = 6, VerticalAlignment = VerticalAlignment.Center };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n titleArea.Children.Add(titleRow);\n if (!string.IsNullOrWhiteSpace(pathLabel))\n titleArea.Children.Add(new TextBlock { Text = pathLabel, Foreground = MutedBrush(), FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, TextWrapping = TextWrapping.NoWrap });\n if (onAddTag is not null) titleArea.Children.Add(CollectionTagsRow(tags ?? Array.Empty(), onAddTag, onRemoveTag));\n header.Children.Add(titleArea);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onMoveToDeck is not null && moveTargets is { Count: > 0 })\n {\n var moveBtn = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" } // Move to folder\n };\n ToolTipService.SetToolTip(moveBtn, \"Move this project to another deck\");\n var moveFlyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n foreach (var (tid, tname) in moveTargets)\n {\n var id = tid;\n var item = new MenuFlyoutItem { Text = \"Move to \" + tname };\n item.Click += (_, _) => onMoveToDeck(id);\n moveFlyout.Items.Add(item);\n }\n moveBtn.Flyout = moveFlyout;\n rightActions.Children.Add(moveBtn);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null, Action? onMove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n var dots = TagDots(session);\n if (dots is not null) meta.Children.Add(dots);\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove, onMove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n // onMove (collections only): (delta, toEnd) manual reorder within the collection.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove, Action? onMove = null)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n var bump = new MenuFlyoutItem\n {\n Text = \"Bump to top of resume list\",\n };\n ToolTipService.SetToolTip(bump, BumpTooltip(session));\n bump.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bump);\n\n if (onMove is not null)\n {\n var move = new MenuFlyoutSubItem { Text = \"Move in collection\" };\n void MoveItem(string text, int delta, bool toEnd) { var it = new MenuFlyoutItem { Text = text }; it.Click += (_, _) => onMove(delta, toEnd); move.Items.Add(it); }\n MoveItem(\"Move to top\", -1, true);\n MoveItem(\"Move up\", -1, false);\n MoveItem(\"Move down\", +1, false);\n MoveItem(\"Move to bottom\", +1, true);\n flyout.Items.Add(move);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n // Add to collection (multi-membership: a chat can live in several collections at once).\n var addToCol = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var col in _archive.Store.Collections.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))\n {\n var name = col.Name;\n var already = col.SessionIds.Contains(session.Id);\n var ci = new MenuFlyoutItem { Text = already ? \"✓ \" + name : name, IsEnabled = !already };\n ci.Click += async (_, _) => { await _archive.AddToCollectionAsync(session, name); SyncStatus.Text = $\"Added to \\\"{name}\\\".\"; RenderCurrent(); };\n addToCol.Items.Add(ci);\n }\n if (addToCol.Items.Count > 0) addToCol.Items.Add(new MenuFlyoutSeparator());\n var newCol = new MenuFlyoutItem { Text = \"New collection...\" };\n newCol.Click += async (_, _) => await AddSessionToNewCollectionAsync(session);\n addToCol.Items.Add(newCol);\n flyout.Items.Add(addToCol);\n\n // Tags: toggle the chat's current tags off, or add a new one - taggable from inside a collection.\n var tagsSub = new MenuFlyoutSubItem { Text = \"Tags\" };\n var userTags = ArchiveService.UserTags(session);\n foreach (var t in userTags)\n {\n var tag = t;\n var ti = new ToggleMenuFlyoutItem { Text = tag, IsChecked = true };\n ti.Click += async (_, _) => { await _archive.RemoveChatTagAsync(session, tag); RenderCurrent(); };\n tagsSub.Items.Add(ti);\n }\n if (userTags.Count > 0) tagsSub.Items.Add(new MenuFlyoutSeparator());\n var addTag = new MenuFlyoutItem { Text = \"Add tag...\" };\n addTag.Click += async (_, _) => { await ShowAddTagDialogAsync(session); RenderCurrent(); };\n tagsSub.Items.Add(addTag);\n flyout.Items.Add(tagsSub);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n // Compact: hide the bulky RGB/HSV text inputs (the \"More\" toggle clipped to \"Mo\"), keeping the\n // spectrum + sliders + hex so the picker stays short and nothing clips in the Settings viewport.\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false,\n IsColorChannelTextInputVisible = false,\n IsHexInputVisible = true\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var bumpItem = new MenuFlyoutItem { Text = \"Bump to top of resume list\" };\n ToolTipService.SetToolTip(bumpItem, BumpTooltip(session));\n bumpItem.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bumpItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n\n private static Windows.UI.Color ColorFromHex(string hex)\n {\n try\n {\n var h = hex.TrimStart('#');\n return Windows.UI.Color.FromArgb(255,\n Convert.ToByte(h.Substring(0, 2), 16),\n Convert.ToByte(h.Substring(2, 2), 16),\n Convert.ToByte(h.Substring(4, 2), 16));\n }\n catch { return Windows.UI.Color.FromArgb(255, 0x60, 0xA5, 0xFA); }\n }\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush RaisedBrush() => (SolidColorBrush)Application.Current.Resources[\"RaisedBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "db3e569cfdbf1ae8b8c7399daa6304e4fc907c22": {"t": 1785756766.5429993, "n": 93920, "h": "66a78d4678ccc8b6", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n // Responsive: below this width the right rail + chat reader can't both fit, so the right\n // panel (secondary actions, all reachable from the header + ... menu) folds away.\n SizeChanged += (_, _) =>\n {\n var narrow = ActualWidth > 0 && ActualWidth < RightPanelMinWidth;\n if (narrow == _narrowLayout) return;\n _narrowLayout = narrow;\n UpdateChrome();\n };\n }\n\n private const double RightPanelMinWidth = 1120;\n private bool _narrowLayout;\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplyFilters();\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n ApplyFilters();\n }\n }\n\n // Kept for callers that pre-set SearchBox.Text (e.g. capture replay); routes through the unified\n // text + tag filter so an active tag filter is always respected.\n private void ApplySearch(string query) => ApplyFilters();\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n bool showRight = sessionContext && !_narrowLayout; // fold the right rail when too narrow to fit\n RightColumnBorder.Visibility = showRight ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = showRight ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n // Header overflow menu: the same secondary actions as the right \"Quick actions\" rail, reachable\n // at ALL widths - critical once the right rail folds away on a narrow window.\n private void HeaderMore_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is null) return;\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false, Placement = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedRight };\n void Add(string text, Action act) { var item = new MenuFlyoutItem { Text = text }; item.Click += (_, _) => act(); flyout.Items.Add(item); }\n Add(\"Add to project\", () => ShowAddToProjectFlyout(HeaderMoreButton, _selected!));\n Add(\"Bump to top of resume list\", () => _ = BumpSession(_selected!));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Copy resume prompt\", () => Copy(\"resume\"));\n Add(\"Copy chat path\", () => Copy(\"path\"));\n Add(\"Copy all code\", () => Copy(\"code\"));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Build restore packet\", () => Navigate(\"Restore\"));\n Add(\"Inspect raw events\", () => Navigate(\"Source\"));\n flyout.ShowAt(HeaderMoreButton);\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n private ArchiveSession? _contentLoadingSession;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n if (!ReferenceEquals(_contentLoadingSession, _selected))\n {\n _contentLoadingSession = _selected;\n _ = EnsureContentThenRenderAsync(_selected);\n }\n return;\n }\n\n var messages = _selected.Messages;\n if (messages.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No conversation messages parsed\", _selected.SourcePath));\n return;\n }\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n finally\n {\n if (ReferenceEquals(_contentLoadingSession, session)) _contentLoadingSession = null;\n }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt),\n pathLabel: group.First().Workspace));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeckPickerBar());\n MainContent.Children.Add(CollectionsControlBar());\n\n var pills = CollectionFilterPills();\n if (pills is not null) MainContent.Children.Add(pills);\n\n // Only the active deck's collections (scoped), then the compound tag filter.\n var activeDeck = _archive.ActiveDeckId;\n var collections = FilteredCollections().Where(c => string.Equals(ArchiveService.CollectionDeck(c), activeDeck, StringComparison.OrdinalIgnoreCase)).ToList();\n if (collections.Count == 0)\n {\n var filtering = AnyCollectionFilterActive();\n MainContent.Children.Add(EmptyBlock(\n filtering ? \"No projects match this filter\" : \"No collections yet\",\n filtering\n ? \"Clear the filter (top-right funnel), or tag a collection with the selected tags.\"\n : \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = CollectionChatsFiltered(collection); // chat-tag filter + layer sort\n var id = collection.Id;\n var name = collection.Name;\n // Count only chats that actually resolve to a stored session (stale ids are ignored),\n // and only show \"N of M\" when a chat-tag filter is hiding some.\n var resolvedTotal = collection.SessionIds.Count(sid => _archive.Store.Sessions.ContainsKey(sid));\n var chatFilterActive = _collChatInclude.Count > 0 || _collChatExclude.Count > 0;\n var subtitle = chatFilterActive ? $\"{sessions.Count} of {resolvedTotal} chats\" : $\"{sessions.Count} chats\";\n var deckName = _archive.Decks.FirstOrDefault(d => string.Equals(d.Id, ArchiveService.CollectionDeck(collection), StringComparison.OrdinalIgnoreCase))?.Name ?? \"Main\";\n MainContent.Children.Add(ExpandableSessionGroup(name, subtitle, sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name, deckName),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id),\n tags: collection.Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),\n onAddTag: () => _ = ShowAddCollectionTagDialogAsync(id, name),\n onRemoveTag: t => _ = RemoveCollectionTagAndRefreshAsync(id, t),\n onMoveSession: (s, delta, toEnd) => _ = ReorderInCollectionAndRefreshAsync(id, s.Id, delta, toEnd),\n moveTargets: _archive.Decks.Where(d => !string.Equals(d.Id, ArchiveService.CollectionDeck(collection), StringComparison.OrdinalIgnoreCase)).Select(d => (d.Id, d.Name)).ToList(),\n onMoveToDeck: deckId => _ = MoveCollectionToDeckAndRefreshAsync(id, deckId)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var filterButton = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 40,\n Height = 40,\n MinWidth = 40,\n MinHeight = 40,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(filterButton, \"Filter projects by tag, and chats within them\");\n filterButton.Click += CollectionFilter_Click;\n\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(filterButton);\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name, string deckName)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name, deckName));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" (deck: {deckName}) - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName, string deckName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\" on deck \\\"{deckName}\\\".\\n\" +\n \"If I gave this chat a name (e.g. \\\"add yourself as codex-claude-local\\\"), set that as its \" +\n \"in-app name too. The name is app-only - it does NOT change your global/native title.\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, requestId, and optional name in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"deck\\\":\\\"{deckName}\\\",\\\"name\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, files it into the project, sets the \" +\n \"optional in-app name, and acks with the same requestId plus resolvedSessionId and persisted. \" +\n \"If the id cannot be resolved it returns an error rather than adding a different chat. Only if \" +\n \"your runtime truly has no session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \" +\n \"\\\"cwd\\\" and \\\"tool\\\". (To only rename, omit project and use \\\"op\\\":\\\"setName\\\".) \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null,\n IReadOnlyList? tags = null, Action? onAddTag = null, Action? onRemoveTag = null,\n string? pathLabel = null, Action? onMoveSession = null,\n IReadOnlyList<(string Id, string Name)>? moveTargets = null, Action? onMoveToDeck = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session,\n onRemoveSession is null ? null : () => onRemoveSession(session),\n onMoveSession is null ? null : (delta, toEnd) => onMoveSession(session, delta, toEnd)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleArea = new StackPanel { Spacing = 6, VerticalAlignment = VerticalAlignment.Center };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n titleArea.Children.Add(titleRow);\n if (!string.IsNullOrWhiteSpace(pathLabel))\n titleArea.Children.Add(new TextBlock { Text = pathLabel, Foreground = MutedBrush(), FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, TextWrapping = TextWrapping.NoWrap });\n if (onAddTag is not null) titleArea.Children.Add(CollectionTagsRow(tags ?? Array.Empty(), onAddTag, onRemoveTag));\n header.Children.Add(titleArea);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onMoveToDeck is not null && moveTargets is { Count: > 0 })\n {\n var moveBtn = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" } // Move to folder\n };\n ToolTipService.SetToolTip(moveBtn, \"Move this project to another deck\");\n var moveFlyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n foreach (var (tid, tname) in moveTargets)\n {\n var id = tid;\n var item = new MenuFlyoutItem { Text = \"Move to \" + tname };\n item.Click += (_, _) => onMoveToDeck(id);\n moveFlyout.Items.Add(item);\n }\n moveBtn.Flyout = moveFlyout;\n rightActions.Children.Add(moveBtn);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null, Action? onMove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n var dots = TagDots(session);\n if (dots is not null) meta.Children.Add(dots);\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove, onMove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n // onMove (collections only): (delta, toEnd) manual reorder within the collection.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove, Action? onMove = null)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n var bump = new MenuFlyoutItem\n {\n Text = \"Bump to top of resume list\",\n };\n ToolTipService.SetToolTip(bump, BumpTooltip(session));\n bump.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bump);\n\n if (onMove is not null)\n {\n var move = new MenuFlyoutSubItem { Text = \"Move in collection\" };\n void MoveItem(string text, int delta, bool toEnd) { var it = new MenuFlyoutItem { Text = text }; it.Click += (_, _) => onMove(delta, toEnd); move.Items.Add(it); }\n MoveItem(\"Move to top\", -1, true);\n MoveItem(\"Move up\", -1, false);\n MoveItem(\"Move down\", +1, false);\n MoveItem(\"Move to bottom\", +1, true);\n flyout.Items.Add(move);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n // Add to collection (multi-membership: a chat can live in several collections at once).\n var addToCol = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var col in _archive.Store.Collections.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))\n {\n var name = col.Name;\n var already = col.SessionIds.Contains(session.Id);\n var ci = new MenuFlyoutItem { Text = already ? \"✓ \" + name : name, IsEnabled = !already };\n ci.Click += async (_, _) => { await _archive.AddToCollectionAsync(session, name); SyncStatus.Text = $\"Added to \\\"{name}\\\".\"; RenderCurrent(); };\n addToCol.Items.Add(ci);\n }\n if (addToCol.Items.Count > 0) addToCol.Items.Add(new MenuFlyoutSeparator());\n var newCol = new MenuFlyoutItem { Text = \"New collection...\" };\n newCol.Click += async (_, _) => await AddSessionToNewCollectionAsync(session);\n addToCol.Items.Add(newCol);\n flyout.Items.Add(addToCol);\n\n // Tags: toggle the chat's current tags off, or add a new one - taggable from inside a collection.\n var tagsSub = new MenuFlyoutSubItem { Text = \"Tags\" };\n var userTags = ArchiveService.UserTags(session);\n foreach (var t in userTags)\n {\n var tag = t;\n var ti = new ToggleMenuFlyoutItem { Text = tag, IsChecked = true };\n ti.Click += async (_, _) => { await _archive.RemoveChatTagAsync(session, tag); RenderCurrent(); };\n tagsSub.Items.Add(ti);\n }\n if (userTags.Count > 0) tagsSub.Items.Add(new MenuFlyoutSeparator());\n var addTag = new MenuFlyoutItem { Text = \"Add tag...\" };\n addTag.Click += async (_, _) => { await ShowAddTagDialogAsync(session); RenderCurrent(); };\n tagsSub.Items.Add(addTag);\n flyout.Items.Add(tagsSub);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n // Compact: hide the bulky RGB/HSV text inputs (the \"More\" toggle clipped to \"Mo\"), keeping the\n // spectrum + sliders + hex so the picker stays short and nothing clips in the Settings viewport.\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false,\n IsColorChannelTextInputVisible = false,\n IsHexInputVisible = true\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var bumpItem = new MenuFlyoutItem { Text = \"Bump to top of resume list\" };\n ToolTipService.SetToolTip(bumpItem, BumpTooltip(session));\n bumpItem.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bumpItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n\n private static Windows.UI.Color ColorFromHex(string hex)\n {\n try\n {\n var h = hex.TrimStart('#');\n return Windows.UI.Color.FromArgb(255,\n Convert.ToByte(h.Substring(0, 2), 16),\n Convert.ToByte(h.Substring(2, 2), 16),\n Convert.ToByte(h.Substring(4, 2), 16));\n }\n catch { return Windows.UI.Color.FromArgb(255, 0x60, 0xA5, 0xFA); }\n }\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush RaisedBrush() => (SolidColorBrush)Application.Current.Resources[\"RaisedBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "5d4f53f8717bdaab3b86f1a8aa76e84dacfbaf1e": {"t": 1785756768.8643117, "n": 91885, "h": "59866a7e84786512", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n // Responsive: below this width the right rail + chat reader can't both fit, so the right\n // panel (secondary actions, all reachable from the header + ... menu) folds away.\n SizeChanged += (_, _) =>\n {\n var narrow = ActualWidth > 0 && ActualWidth < RightPanelMinWidth;\n if (narrow == _narrowLayout) return;\n _narrowLayout = narrow;\n UpdateChrome();\n };\n }\n\n private const double RightPanelMinWidth = 1120;\n private bool _narrowLayout;\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplyFilters();\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n ApplyFilters();\n }\n }\n\n // Kept for callers that pre-set SearchBox.Text (e.g. capture replay); routes through the unified\n // text + tag filter so an active tag filter is always respected.\n private void ApplySearch(string query) => ApplyFilters();\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n bool showRight = sessionContext && !_narrowLayout; // fold the right rail when too narrow to fit\n RightColumnBorder.Visibility = showRight ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = showRight ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n // Header overflow menu: the same secondary actions as the right \"Quick actions\" rail, reachable\n // at ALL widths - critical once the right rail folds away on a narrow window.\n private void HeaderMore_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is null) return;\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false, Placement = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedRight };\n void Add(string text, Action act) { var item = new MenuFlyoutItem { Text = text }; item.Click += (_, _) => act(); flyout.Items.Add(item); }\n Add(\"Add to project\", () => ShowAddToProjectFlyout(HeaderMoreButton, _selected!));\n Add(\"Bump to top of resume list\", () => _ = BumpSession(_selected!));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Copy resume prompt\", () => Copy(\"resume\"));\n Add(\"Copy chat path\", () => Copy(\"path\"));\n Add(\"Copy all code\", () => Copy(\"code\"));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Build restore packet\", () => Navigate(\"Restore\"));\n Add(\"Inspect raw events\", () => Navigate(\"Source\"));\n flyout.ShowAt(HeaderMoreButton);\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n private ArchiveSession? _contentLoadingSession;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n if (!ReferenceEquals(_contentLoadingSession, _selected))\n {\n _contentLoadingSession = _selected;\n _ = EnsureContentThenRenderAsync(_selected);\n }\n return;\n }\n\n var messages = _selected.Messages;\n if (messages.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No conversation messages parsed\", _selected.SourcePath));\n return;\n }\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n finally\n {\n if (ReferenceEquals(_contentLoadingSession, session)) _contentLoadingSession = null;\n }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt),\n pathLabel: group.First().Workspace));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var pills = CollectionFilterPills();\n if (pills is not null) MainContent.Children.Add(pills);\n\n var collections = FilteredCollections();\n if (collections.Count == 0)\n {\n var filtering = AnyCollectionFilterActive();\n MainContent.Children.Add(EmptyBlock(\n filtering ? \"No projects match this filter\" : \"No collections yet\",\n filtering\n ? \"Clear the filter (top-right funnel), or tag a collection with the selected tags.\"\n : \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = CollectionChatsFiltered(collection); // chat-tag filter + layer sort\n var id = collection.Id;\n var name = collection.Name;\n // Count only chats that actually resolve to a stored session (stale ids are ignored),\n // and only show \"N of M\" when a chat-tag filter is hiding some.\n var resolvedTotal = collection.SessionIds.Count(sid => _archive.Store.Sessions.ContainsKey(sid));\n var chatFilterActive = _collChatInclude.Count > 0 || _collChatExclude.Count > 0;\n var subtitle = chatFilterActive ? $\"{sessions.Count} of {resolvedTotal} chats\" : $\"{sessions.Count} chats\";\n MainContent.Children.Add(ExpandableSessionGroup(name, subtitle, sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id),\n tags: collection.Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),\n onAddTag: () => _ = ShowAddCollectionTagDialogAsync(id, name),\n onRemoveTag: t => _ = RemoveCollectionTagAndRefreshAsync(id, t),\n onMoveSession: (s, delta, toEnd) => _ = ReorderInCollectionAndRefreshAsync(id, s.Id, delta, toEnd)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var filterButton = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 40,\n Height = 40,\n MinWidth = 40,\n MinHeight = 40,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(filterButton, \"Filter projects by tag, and chats within them\");\n filterButton.Click += CollectionFilter_Click;\n\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(filterButton);\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"If I gave this chat a name (e.g. \\\"add yourself as codex-claude-local\\\"), set that as its \" +\n \"in-app name too. The name is app-only - it does NOT change your global/native title.\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, requestId, and optional name in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"name\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, files it into the project, sets the \" +\n \"optional in-app name, and acks with the same requestId plus resolvedSessionId and persisted. \" +\n \"If the id cannot be resolved it returns an error rather than adding a different chat. Only if \" +\n \"your runtime truly has no session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \" +\n \"\\\"cwd\\\" and \\\"tool\\\". (To only rename, omit project and use \\\"op\\\":\\\"setName\\\".) \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null,\n IReadOnlyList? tags = null, Action? onAddTag = null, Action? onRemoveTag = null,\n string? pathLabel = null, Action? onMoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session,\n onRemoveSession is null ? null : () => onRemoveSession(session),\n onMoveSession is null ? null : (delta, toEnd) => onMoveSession(session, delta, toEnd)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleArea = new StackPanel { Spacing = 6, VerticalAlignment = VerticalAlignment.Center };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n titleArea.Children.Add(titleRow);\n if (!string.IsNullOrWhiteSpace(pathLabel))\n titleArea.Children.Add(new TextBlock { Text = pathLabel, Foreground = MutedBrush(), FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, TextWrapping = TextWrapping.NoWrap });\n if (onAddTag is not null) titleArea.Children.Add(CollectionTagsRow(tags ?? Array.Empty(), onAddTag, onRemoveTag));\n header.Children.Add(titleArea);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null, Action? onMove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n var dots = TagDots(session);\n if (dots is not null) meta.Children.Add(dots);\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove, onMove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n // onMove (collections only): (delta, toEnd) manual reorder within the collection.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove, Action? onMove = null)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n var bump = new MenuFlyoutItem\n {\n Text = \"Bump to top of resume list\",\n };\n ToolTipService.SetToolTip(bump, BumpTooltip(session));\n bump.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bump);\n\n if (onMove is not null)\n {\n var move = new MenuFlyoutSubItem { Text = \"Move in collection\" };\n void MoveItem(string text, int delta, bool toEnd) { var it = new MenuFlyoutItem { Text = text }; it.Click += (_, _) => onMove(delta, toEnd); move.Items.Add(it); }\n MoveItem(\"Move to top\", -1, true);\n MoveItem(\"Move up\", -1, false);\n MoveItem(\"Move down\", +1, false);\n MoveItem(\"Move to bottom\", +1, true);\n flyout.Items.Add(move);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n // Add to collection (multi-membership: a chat can live in several collections at once).\n var addToCol = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var col in _archive.Store.Collections.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))\n {\n var name = col.Name;\n var already = col.SessionIds.Contains(session.Id);\n var ci = new MenuFlyoutItem { Text = already ? \"✓ \" + name : name, IsEnabled = !already };\n ci.Click += async (_, _) => { await _archive.AddToCollectionAsync(session, name); SyncStatus.Text = $\"Added to \\\"{name}\\\".\"; RenderCurrent(); };\n addToCol.Items.Add(ci);\n }\n if (addToCol.Items.Count > 0) addToCol.Items.Add(new MenuFlyoutSeparator());\n var newCol = new MenuFlyoutItem { Text = \"New collection...\" };\n newCol.Click += async (_, _) => await AddSessionToNewCollectionAsync(session);\n addToCol.Items.Add(newCol);\n flyout.Items.Add(addToCol);\n\n // Tags: toggle the chat's current tags off, or add a new one - taggable from inside a collection.\n var tagsSub = new MenuFlyoutSubItem { Text = \"Tags\" };\n var userTags = ArchiveService.UserTags(session);\n foreach (var t in userTags)\n {\n var tag = t;\n var ti = new ToggleMenuFlyoutItem { Text = tag, IsChecked = true };\n ti.Click += async (_, _) => { await _archive.RemoveChatTagAsync(session, tag); RenderCurrent(); };\n tagsSub.Items.Add(ti);\n }\n if (userTags.Count > 0) tagsSub.Items.Add(new MenuFlyoutSeparator());\n var addTag = new MenuFlyoutItem { Text = \"Add tag...\" };\n addTag.Click += async (_, _) => { await ShowAddTagDialogAsync(session); RenderCurrent(); };\n tagsSub.Items.Add(addTag);\n flyout.Items.Add(tagsSub);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n // Compact: hide the bulky RGB/HSV text inputs (the \"More\" toggle clipped to \"Mo\"), keeping the\n // spectrum + sliders + hex so the picker stays short and nothing clips in the Settings viewport.\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false,\n IsColorChannelTextInputVisible = false,\n IsHexInputVisible = true\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var bumpItem = new MenuFlyoutItem { Text = \"Bump to top of resume list\" };\n ToolTipService.SetToolTip(bumpItem, BumpTooltip(session));\n bumpItem.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bumpItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n\n private static Windows.UI.Color ColorFromHex(string hex)\n {\n try\n {\n var h = hex.TrimStart('#');\n return Windows.UI.Color.FromArgb(255,\n Convert.ToByte(h.Substring(0, 2), 16),\n Convert.ToByte(h.Substring(2, 2), 16),\n Convert.ToByte(h.Substring(4, 2), 16));\n }\n catch { return Windows.UI.Color.FromArgb(255, 0x60, 0xA5, 0xFA); }\n }\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush RaisedBrush() => (SolidColorBrush)Application.Current.Resources[\"RaisedBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "f3fc0ee0e2916f6b2a24ced555d1d22c87f13db6": {"t": 1785756770.720961, "n": 90445, "h": "3e880cc3441515f9", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n // Responsive: below this width the right rail + chat reader can't both fit, so the right\n // panel (secondary actions, all reachable from the header + ... menu) folds away.\n SizeChanged += (_, _) =>\n {\n var narrow = ActualWidth > 0 && ActualWidth < RightPanelMinWidth;\n if (narrow == _narrowLayout) return;\n _narrowLayout = narrow;\n UpdateChrome();\n };\n }\n\n private const double RightPanelMinWidth = 1120;\n private bool _narrowLayout;\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplyFilters();\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n ApplyFilters();\n }\n }\n\n // Kept for callers that pre-set SearchBox.Text (e.g. capture replay); routes through the unified\n // text + tag filter so an active tag filter is always respected.\n private void ApplySearch(string query) => ApplyFilters();\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n bool showRight = sessionContext && !_narrowLayout; // fold the right rail when too narrow to fit\n RightColumnBorder.Visibility = showRight ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = showRight ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n // Header overflow menu: the same secondary actions as the right \"Quick actions\" rail, reachable\n // at ALL widths - critical once the right rail folds away on a narrow window.\n private void HeaderMore_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is null) return;\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false, Placement = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedRight };\n void Add(string text, Action act) { var item = new MenuFlyoutItem { Text = text }; item.Click += (_, _) => act(); flyout.Items.Add(item); }\n Add(\"Add to project\", () => ShowAddToProjectFlyout(HeaderMoreButton, _selected!));\n Add(\"Bump to top of resume list\", () => _ = BumpSession(_selected!));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Copy resume prompt\", () => Copy(\"resume\"));\n Add(\"Copy chat path\", () => Copy(\"path\"));\n Add(\"Copy all code\", () => Copy(\"code\"));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Build restore packet\", () => Navigate(\"Restore\"));\n Add(\"Inspect raw events\", () => Navigate(\"Source\"));\n flyout.ShowAt(HeaderMoreButton);\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n private ArchiveSession? _contentLoadingSession;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n if (!ReferenceEquals(_contentLoadingSession, _selected))\n {\n _contentLoadingSession = _selected;\n _ = EnsureContentThenRenderAsync(_selected);\n }\n return;\n }\n\n var messages = _selected.Messages;\n if (messages.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No conversation messages parsed\", _selected.SourcePath));\n return;\n }\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n finally\n {\n if (ReferenceEquals(_contentLoadingSession, session)) _contentLoadingSession = null;\n }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt),\n pathLabel: group.First().Workspace));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var filterBar = CollectionTagFilterBar();\n if (filterBar is not null) MainContent.Children.Add(filterBar);\n\n var collections = _archive.Store.Collections.Values\n .Where(c => _activeCollectionTagFilters.Count == 0\n || _activeCollectionTagFilters.Any(t => c.Tags.Any(ct => string.Equals(ct, t, StringComparison.OrdinalIgnoreCase))))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\n _activeCollectionTagFilters.Count > 0 ? \"No collections match this tag filter\" : \"No collections yet\",\n _activeCollectionTagFilters.Count > 0\n ? \"Clear the filter above, or tag a collection with one of the selected tags.\"\n : \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id),\n tags: collection.Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),\n onAddTag: () => _ = ShowAddCollectionTagDialogAsync(id, name),\n onRemoveTag: t => _ = RemoveCollectionTagAndRefreshAsync(id, t)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"If I gave this chat a name (e.g. \\\"add yourself as codex-claude-local\\\"), set that as its \" +\n \"in-app name too. The name is app-only - it does NOT change your global/native title.\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, requestId, and optional name in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"name\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, files it into the project, sets the \" +\n \"optional in-app name, and acks with the same requestId plus resolvedSessionId and persisted. \" +\n \"If the id cannot be resolved it returns an error rather than adding a different chat. Only if \" +\n \"your runtime truly has no session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \" +\n \"\\\"cwd\\\" and \\\"tool\\\". (To only rename, omit project and use \\\"op\\\":\\\"setName\\\".) \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null,\n IReadOnlyList? tags = null, Action? onAddTag = null, Action? onRemoveTag = null,\n string? pathLabel = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleArea = new StackPanel { Spacing = 6, VerticalAlignment = VerticalAlignment.Center };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n titleArea.Children.Add(titleRow);\n if (!string.IsNullOrWhiteSpace(pathLabel))\n titleArea.Children.Add(new TextBlock { Text = pathLabel, Foreground = MutedBrush(), FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, TextWrapping = TextWrapping.NoWrap });\n if (onAddTag is not null) titleArea.Children.Add(CollectionTagsRow(tags ?? Array.Empty(), onAddTag, onRemoveTag));\n header.Children.Add(titleArea);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n var dots = TagDots(session);\n if (dots is not null) meta.Children.Add(dots);\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n var bump = new MenuFlyoutItem\n {\n Text = \"Bump to top of resume list\",\n };\n ToolTipService.SetToolTip(bump, BumpTooltip(session));\n bump.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bump);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n // Add to collection (multi-membership: a chat can live in several collections at once).\n var addToCol = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var col in _archive.Store.Collections.Values.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase))\n {\n var name = col.Name;\n var already = col.SessionIds.Contains(session.Id);\n var ci = new MenuFlyoutItem { Text = already ? \"✓ \" + name : name, IsEnabled = !already };\n ci.Click += async (_, _) => { await _archive.AddToCollectionAsync(session, name); SyncStatus.Text = $\"Added to \\\"{name}\\\".\"; RenderCurrent(); };\n addToCol.Items.Add(ci);\n }\n if (addToCol.Items.Count > 0) addToCol.Items.Add(new MenuFlyoutSeparator());\n var newCol = new MenuFlyoutItem { Text = \"New collection...\" };\n newCol.Click += async (_, _) => await AddSessionToNewCollectionAsync(session);\n addToCol.Items.Add(newCol);\n flyout.Items.Add(addToCol);\n\n // Tags: toggle the chat's current tags off, or add a new one - taggable from inside a collection.\n var tagsSub = new MenuFlyoutSubItem { Text = \"Tags\" };\n var userTags = ArchiveService.UserTags(session);\n foreach (var t in userTags)\n {\n var tag = t;\n var ti = new ToggleMenuFlyoutItem { Text = tag, IsChecked = true };\n ti.Click += async (_, _) => { await _archive.RemoveChatTagAsync(session, tag); RenderCurrent(); };\n tagsSub.Items.Add(ti);\n }\n if (userTags.Count > 0) tagsSub.Items.Add(new MenuFlyoutSeparator());\n var addTag = new MenuFlyoutItem { Text = \"Add tag...\" };\n addTag.Click += async (_, _) => { await ShowAddTagDialogAsync(session); RenderCurrent(); };\n tagsSub.Items.Add(addTag);\n flyout.Items.Add(tagsSub);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n // Compact: hide the bulky RGB/HSV text inputs (the \"More\" toggle clipped to \"Mo\"), keeping the\n // spectrum + sliders + hex so the picker stays short and nothing clips in the Settings viewport.\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false,\n IsColorChannelTextInputVisible = false,\n IsHexInputVisible = true\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var bumpItem = new MenuFlyoutItem { Text = \"Bump to top of resume list\" };\n ToolTipService.SetToolTip(bumpItem, BumpTooltip(session));\n bumpItem.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bumpItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n\n private static Windows.UI.Color ColorFromHex(string hex)\n {\n try\n {\n var h = hex.TrimStart('#');\n return Windows.UI.Color.FromArgb(255,\n Convert.ToByte(h.Substring(0, 2), 16),\n Convert.ToByte(h.Substring(2, 2), 16),\n Convert.ToByte(h.Substring(4, 2), 16));\n }\n catch { return Windows.UI.Color.FromArgb(255, 0x60, 0xA5, 0xFA); }\n }\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush RaisedBrush() => (SolidColorBrush)Application.Current.Resources[\"RaisedBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "da60fe86cdb47d72d8dbc55a9c362b519aadaef5": {"t": 1785756772.5648694, "n": 88429, "h": "f07e7539c3d6b31d", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n // Responsive: below this width the right rail + chat reader can't both fit, so the right\n // panel (secondary actions, all reachable from the header + ... menu) folds away.\n SizeChanged += (_, _) =>\n {\n var narrow = ActualWidth > 0 && ActualWidth < RightPanelMinWidth;\n if (narrow == _narrowLayout) return;\n _narrowLayout = narrow;\n UpdateChrome();\n };\n }\n\n private const double RightPanelMinWidth = 1120;\n private bool _narrowLayout;\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplyFilters();\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n ApplyFilters();\n }\n }\n\n // Kept for callers that pre-set SearchBox.Text (e.g. capture replay); routes through the unified\n // text + tag filter so an active tag filter is always respected.\n private void ApplySearch(string query) => ApplyFilters();\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n bool showRight = sessionContext && !_narrowLayout; // fold the right rail when too narrow to fit\n RightColumnBorder.Visibility = showRight ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = showRight ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n // Header overflow menu: the same secondary actions as the right \"Quick actions\" rail, reachable\n // at ALL widths - critical once the right rail folds away on a narrow window.\n private void HeaderMore_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is null) return;\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false, Placement = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.BottomEdgeAlignedRight };\n void Add(string text, Action act) { var item = new MenuFlyoutItem { Text = text }; item.Click += (_, _) => act(); flyout.Items.Add(item); }\n Add(\"Add to project\", () => ShowAddToProjectFlyout(HeaderMoreButton, _selected!));\n Add(\"Bump to top of resume list\", () => _ = BumpSession(_selected!));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Copy resume prompt\", () => Copy(\"resume\"));\n Add(\"Copy chat path\", () => Copy(\"path\"));\n Add(\"Copy all code\", () => Copy(\"code\"));\n flyout.Items.Add(new MenuFlyoutSeparator());\n Add(\"Build restore packet\", () => Navigate(\"Restore\"));\n Add(\"Inspect raw events\", () => Navigate(\"Source\"));\n flyout.ShowAt(HeaderMoreButton);\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n private ArchiveSession? _contentLoadingSession;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n if (!ReferenceEquals(_contentLoadingSession, _selected))\n {\n _contentLoadingSession = _selected;\n _ = EnsureContentThenRenderAsync(_selected);\n }\n return;\n }\n\n var messages = _selected.Messages;\n if (messages.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No conversation messages parsed\", _selected.SourcePath));\n return;\n }\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n finally\n {\n if (ReferenceEquals(_contentLoadingSession, session)) _contentLoadingSession = null;\n }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt),\n pathLabel: group.First().Workspace));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var filterBar = CollectionTagFilterBar();\n if (filterBar is not null) MainContent.Children.Add(filterBar);\n\n var collections = _archive.Store.Collections.Values\n .Where(c => _activeCollectionTagFilters.Count == 0\n || _activeCollectionTagFilters.Any(t => c.Tags.Any(ct => string.Equals(ct, t, StringComparison.OrdinalIgnoreCase))))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\n _activeCollectionTagFilters.Count > 0 ? \"No collections match this tag filter\" : \"No collections yet\",\n _activeCollectionTagFilters.Count > 0\n ? \"Clear the filter above, or tag a collection with one of the selected tags.\"\n : \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id),\n tags: collection.Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),\n onAddTag: () => _ = ShowAddCollectionTagDialogAsync(id, name),\n onRemoveTag: t => _ = RemoveCollectionTagAndRefreshAsync(id, t)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"If I gave this chat a name (e.g. \\\"add yourself as codex-claude-local\\\"), set that as its \" +\n \"in-app name too. The name is app-only - it does NOT change your global/native title.\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, requestId, and optional name in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"name\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, files it into the project, sets the \" +\n \"optional in-app name, and acks with the same requestId plus resolvedSessionId and persisted. \" +\n \"If the id cannot be resolved it returns an error rather than adding a different chat. Only if \" +\n \"your runtime truly has no session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \" +\n \"\\\"cwd\\\" and \\\"tool\\\". (To only rename, omit project and use \\\"op\\\":\\\"setName\\\".) \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null,\n IReadOnlyList? tags = null, Action? onAddTag = null, Action? onRemoveTag = null,\n string? pathLabel = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleArea = new StackPanel { Spacing = 6, VerticalAlignment = VerticalAlignment.Center };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n titleArea.Children.Add(titleRow);\n if (!string.IsNullOrWhiteSpace(pathLabel))\n titleArea.Children.Add(new TextBlock { Text = pathLabel, Foreground = MutedBrush(), FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, TextWrapping = TextWrapping.NoWrap });\n if (onAddTag is not null) titleArea.Children.Add(CollectionTagsRow(tags ?? Array.Empty(), onAddTag, onRemoveTag));\n header.Children.Add(titleArea);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n var bump = new MenuFlyoutItem\n {\n Text = \"Bump to top of resume list\",\n };\n ToolTipService.SetToolTip(bump, BumpTooltip(session));\n bump.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bump);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n // Compact: hide the bulky RGB/HSV text inputs (the \"More\" toggle clipped to \"Mo\"), keeping the\n // spectrum + sliders + hex so the picker stays short and nothing clips in the Settings viewport.\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false,\n IsColorChannelTextInputVisible = false,\n IsHexInputVisible = true\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var bumpItem = new MenuFlyoutItem { Text = \"Bump to top of resume list\" };\n ToolTipService.SetToolTip(bumpItem, BumpTooltip(session));\n bumpItem.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bumpItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n\n private static Windows.UI.Color ColorFromHex(string hex)\n {\n try\n {\n var h = hex.TrimStart('#');\n return Windows.UI.Color.FromArgb(255,\n Convert.ToByte(h.Substring(0, 2), 16),\n Convert.ToByte(h.Substring(2, 2), 16),\n Convert.ToByte(h.Substring(4, 2), 16));\n }\n catch { return Windows.UI.Color.FromArgb(255, 0x60, 0xA5, 0xFA); }\n }\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush RaisedBrush() => (SolidColorBrush)Application.Current.Resources[\"RaisedBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "d5022a55d956d859a580279dd72c8a5d6d49e7c7": {"t": 1785756774.6691682, "n": 85438, "h": "a2ede13f887ccdf1", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplyFilters();\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n ApplyFilters();\n }\n }\n\n // Kept for callers that pre-set SearchBox.Text (e.g. capture replay); routes through the unified\n // text + tag filter so an active tag filter is always respected.\n private void ApplySearch(string query) => ApplyFilters();\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n private ArchiveSession? _contentLoadingSession;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n if (!ReferenceEquals(_contentLoadingSession, _selected))\n {\n _contentLoadingSession = _selected;\n _ = EnsureContentThenRenderAsync(_selected);\n }\n return;\n }\n\n var messages = _selected.Messages;\n if (messages.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No conversation messages parsed\", _selected.SourcePath));\n return;\n }\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n finally\n {\n if (ReferenceEquals(_contentLoadingSession, session)) _contentLoadingSession = null;\n }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var filterBar = CollectionTagFilterBar();\n if (filterBar is not null) MainContent.Children.Add(filterBar);\n\n var collections = _archive.Store.Collections.Values\n .Where(c => _activeCollectionTagFilters.Count == 0\n || _activeCollectionTagFilters.Any(t => c.Tags.Any(ct => string.Equals(ct, t, StringComparison.OrdinalIgnoreCase))))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\n _activeCollectionTagFilters.Count > 0 ? \"No collections match this tag filter\" : \"No collections yet\",\n _activeCollectionTagFilters.Count > 0\n ? \"Clear the filter above, or tag a collection with one of the selected tags.\"\n : \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id),\n tags: collection.Tags.OrderBy(t => t, StringComparer.OrdinalIgnoreCase).ToList(),\n onAddTag: () => _ = ShowAddCollectionTagDialogAsync(id, name),\n onRemoveTag: t => _ = RemoveCollectionTagAndRefreshAsync(id, t)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"If I gave this chat a name (e.g. \\\"add yourself as codex-claude-local\\\"), set that as its \" +\n \"in-app name too. The name is app-only - it does NOT change your global/native title.\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, requestId, and optional name in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"name\\\":\\\"\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, files it into the project, sets the \" +\n \"optional in-app name, and acks with the same requestId plus resolvedSessionId and persisted. \" +\n \"If the id cannot be resolved it returns an error rather than adding a different chat. Only if \" +\n \"your runtime truly has no session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \" +\n \"\\\"cwd\\\" and \\\"tool\\\". (To only rename, omit project and use \\\"op\\\":\\\"setName\\\".) \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null,\n IReadOnlyList? tags = null, Action? onAddTag = null, Action? onRemoveTag = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleArea = new StackPanel { Spacing = 6, VerticalAlignment = VerticalAlignment.Center };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n titleArea.Children.Add(titleRow);\n if (onAddTag is not null) titleArea.Children.Add(CollectionTagsRow(tags ?? Array.Empty(), onAddTag, onRemoveTag));\n header.Children.Add(titleArea);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n var bump = new MenuFlyoutItem\n {\n Text = \"Bump to top of resume list\",\n };\n ToolTipService.SetToolTip(bump, BumpTooltip(session));\n bump.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bump);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var bumpItem = new MenuFlyoutItem { Text = \"Bump to top of resume list\" };\n ToolTipService.SetToolTip(bumpItem, BumpTooltip(session));\n bumpItem.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bumpItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "01d9c0e8ee94da6a1454953665cb107c4b1dafc2": {"t": 1785756799.2188976, "n": 84859, "h": "04997ba0696d010c", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, and requestId in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, verifies the project membership, and \" +\n \"acks with the same requestId plus resolvedSessionId and persisted. If the id cannot be resolved \" +\n \"it returns an error rather than adding a different chat. Only if your runtime truly has no \" +\n \"session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \\\"cwd\\\" and \\\"tool\\\". \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n var bump = new MenuFlyoutItem\n {\n Text = \"Bump to top of resume list\",\n };\n ToolTipService.SetToolTip(bump, BumpTooltip(session));\n bump.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bump);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var bumpItem = new MenuFlyoutItem { Text = \"Bump to top of resume list\" };\n ToolTipService.SetToolTip(bumpItem, BumpTooltip(session));\n bumpItem.Click += async (_, _) => await BumpSession(session);\n flyout.Items.Add(bumpItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "101f10f69e87fa63c4cf8ea4e17bbf6b2eeca6e3": {"t": 1785756801.1332397, "n": 84332, "h": "0fa7233c410cf86a", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n StartLiveReader();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n // Live reader: while a chat is open, poll its source transcript and tail new turns as the agent\n // writes them - so you watch a rollout fill in. We only auto-follow when you're at the bottom; if\n // you scroll up to read history, we leave you there until you return to the latest.\n private Microsoft.UI.Dispatching.DispatcherQueueTimer? _liveTimer;\n private DateTime _liveMtime = DateTime.MinValue;\n private ArchiveSession? _liveSession;\n private bool _liveBusy;\n\n private void StartLiveReader()\n {\n _liveTimer = DispatcherQueue.CreateTimer();\n _liveTimer.Interval = TimeSpan.FromMilliseconds(1500);\n _liveTimer.Tick += async (_, _) => await LiveTickAsync();\n _liveTimer.Start();\n }\n\n private async Task LiveTickAsync()\n {\n if (_liveBusy || _screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n\n // First sight of this chat -> set the baseline, don't repaint.\n if (!ReferenceEquals(_selected, _liveSession))\n {\n _liveSession = _selected;\n _liveMtime = _archive.SourceWriteTimeUtc(_selected);\n return;\n }\n\n var mtime = _archive.SourceWriteTimeUtc(_selected);\n if (mtime <= _liveMtime) return; // nothing new on disk\n if (MainScroller.ScrollableHeight - MainScroller.VerticalOffset > 120) return; // reading history; don't yank\n\n _liveBusy = true;\n try\n {\n _liveMtime = mtime;\n await _archive.ReloadContentAsync(_selected);\n if (_screen == \"Archive\" && ReferenceEquals(_selected, _liveSession))\n {\n _scrollArchiveToBottom = true;\n RenderArchive();\n }\n }\n catch (Exception ex) { Diag.Log(\"Live tick: \" + ex.Message); }\n finally { _liveBusy = false; }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; the app resolves the runtime id to its stored chat key.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Generate a requestId, then append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id, tool, and requestId in):\\n\" +\n $\" {{\\\"op\\\":\\\"addSelfToProject\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"\\\"}}\\n\\n\" +\n \"The app resolves your runtime id to its stored chat key, verifies the project membership, and \" +\n \"acks with the same requestId plus resolvedSessionId and persisted. If the id cannot be resolved \" +\n \"it returns an error rather than adding a different chat. Only if your runtime truly has no \" +\n \"session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \\\"cwd\\\" and \\\"tool\\\". \" +\n \"Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private UIElement MessageBubble(ArchiveMessage message)\n {\n if (message.EffectiveKind == \"tool\") return ToolStepView(message);\n\n var isUser = message.EffectiveKind == \"user\";\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock\n {\n Text = (isUser ? \"You\" : \"Assistant\") + \" - \" + FormatDate(message.Timestamp),\n Foreground = isUser ? AccentBrush() : MutedBrush(),\n FontSize = 12,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 900,\n HorizontalAlignment = isUser ? HorizontalAlignment.Right : HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n // A tool call rendered as a single compact, collapsible step: badge + command on one line; expand\n // for the full command and its output. Keeps the transcript reading like a chat, not a data dump.\n private UIElement ToolStepView(ArchiveMessage m)\n {\n var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n header.Children.Add(new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 120, 170, 255)),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(7, 1, 7, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = m.ToolName, Foreground = StrongBrush(), FontSize = 11, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n });\n header.Children.Add(new TextBlock\n {\n Text = FirstLine(m.Text),\n Foreground = MutedBrush(),\n FontSize = 12,\n FontFamily = new FontFamily(\"Consolas\"),\n TextTrimming = TextTrimming.CharacterEllipsis,\n MaxLines = 1,\n VerticalAlignment = VerticalAlignment.Center\n });\n\n var body = new StackPanel { Spacing = 8 };\n if (!string.IsNullOrWhiteSpace(m.Text)) body.Children.Add(MonoBlock(m.Text, muted: false));\n if (!string.IsNullOrWhiteSpace(m.ToolOutput)) body.Children.Add(MonoBlock(m.ToolOutput, muted: true));\n\n var expander = new Expander\n {\n Header = header,\n Content = body,\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10, 2, 10, 2),\n HorizontalAlignment = HorizontalAlignment.Left,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n MaxWidth = 900\n };\n return expander;\n }\n\n private UIElement MonoBlock(string text, bool muted) => new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(10),\n Child = new TextBlock\n {\n Text = CapDisplay(text, 4000),\n FontFamily = new FontFamily(\"Consolas\"),\n FontSize = 12,\n Foreground = muted ? MutedBrush() : StrongBrush(),\n TextWrapping = TextWrapping.Wrap\n }\n };\n\n private static string FirstLine(string s)\n {\n if (string.IsNullOrEmpty(s)) return \"\";\n var i = s.IndexOfAny(new[] { '\\n', '\\r' });\n var line = i < 0 ? s : s[..i];\n return line.Length > 200 ? line[..200] : line;\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentBrush() => new(_accentColor);\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "31a957fe79b4f6205b7bc7d401081d3c578d7f10": {"t": 1785756803.0796525, "n": 79050, "h": "9c3ed7964fd25fe9", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\\n\" +\n \"1. Get YOUR exact session id from your environment (you already have it):\\n\" +\n \" - Codex: the CODEX_THREAD_ID environment variable\\n\" +\n \" - Claude: the CLAUDE_CODE_SESSION_ID environment variable\\n\" +\n \"2. Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \" The line (put your real id in, and your tool):\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex|claude\\\"}}\\n\\n\" +\n \"The app files THAT exact chat - it never guesses. If the id can't be found it returns an error \" +\n \"rather than adding the wrong chat, so check the ack in agent-outbox.jsonl. (Only if your runtime \" +\n \"truly has no session-id variable, fall back to \\\"target\\\":\\\"self\\\" with your real \\\"cwd\\\" and \" +\n \"\\\"tool\\\".) Full protocol: AGENTS.md next to the inbox file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "daf63ffa7069781bddca0978ac490adfef869ced": {"t": 1785756804.5490043, "n": 78820, "h": "79d38f02010fc64a", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \"The line - set \\\"cwd\\\" to YOUR current working directory (forward slashes), which you already know:\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"target\\\":\\\"self\\\",\\\"cwd\\\":\\\"C:/your/current/working/dir\\\"}}\\n\" +\n \"How it resolves: the app re-scans and files the chat whose transcript is being written right \" +\n \"now in that folder (i.e. you) - so it always picks the live chat, not an old sibling. If you \" +\n \"truly don't know your cwd, omit it and the app uses the most recently active chat. Full \" +\n \"protocol: AGENTS.md next to that file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n var copyResume = new MenuFlyoutItem { Text = \"Copy agent resume prompt\" };\n copyResume.Click += async (_, _) =>\n {\n await _archive.EnsureContentAsync(session);\n SetClipboardText(_archive.CopyPayload(session, \"resume\"));\n SyncStatus.Text = \"Copied a resume prompt - paste it into a fresh Claude/Codex agent.\";\n };\n flyout.Items.Add(copyResume);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "4d3dbd70e55c68816b9a2f76064435e24f632d2d": {"t": 1785756806.2875257, "n": 78406, "h": "7290c84dd162bfd8", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific - they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n }\n else\n {\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n RenderRecentlyDeleted(); // a safety net for accidental deletes (renders nothing when empty)\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n var headerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n headerActions.Children.Add(BackupMenuButton());\n headerActions.Children.Add(newButton);\n Grid.SetColumn(headerActions, 1);\n header.Children.Add(headerActions);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \"The line - set \\\"cwd\\\" to YOUR current working directory (forward slashes), which you already know:\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"target\\\":\\\"self\\\",\\\"cwd\\\":\\\"C:/your/current/working/dir\\\"}}\\n\" +\n \"How it resolves: the app re-scans and files the chat whose transcript is being written right \" +\n \"now in that folder (i.e. you) - so it always picks the live chat, not an old sibling. If you \" +\n \"truly don't know your cwd, omit it and the app uses the most recently active chat. Full \" +\n \"protocol: AGENTS.md next to that file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive - only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n...(truncated - reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file -\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "2cfd5dcc9e2d8e4d904a06be18a2aeb188aaa66d": {"t": 1785756807.9750845, "n": 77985, "h": "17362116a8a97b93", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n return;\n }\n\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(newButton, 1);\n header.Children.Add(newButton);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \"The line — set \\\"cwd\\\" to YOUR current working directory (forward slashes), which you already know:\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"target\\\":\\\"self\\\",\\\"cwd\\\":\\\"C:/your/current/working/dir\\\"}}\\n\" +\n \"How it resolves: the app re-scans and files the chat whose transcript is being written right \" +\n \"now in that folder (i.e. you) - so it always picks the live chat, not an old sibling. If you \" +\n \"truly don't know your cwd, omit it and the app uses the most recently active chat. Full \" +\n \"protocol: AGENTS.md next to that file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n…(truncated — reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file —\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\\uE74D\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\\uE712\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "d1bef540c4d5416c05470a9ae8b34873d865ae83": {"t": 1785756809.8773682, "n": 77976, "h": "864aeec05875e326", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n return;\n }\n\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(newButton, 1);\n header.Children.Add(newButton);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \"The line — set \\\"cwd\\\" to YOUR current working directory (forward slashes), which you already know:\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"target\\\":\\\"self\\\",\\\"cwd\\\":\\\"C:/your/current/working/dir\\\"}}\\n\" +\n \"How it resolves: the app re-scans and files the chat whose transcript is being written right \" +\n \"now in that folder (i.e. you) - so it always picks the live chat, not an old sibling. If you \" +\n \"truly don't know your cwd, omit it and the app uses the most recently active chat. Full \" +\n \"protocol: AGENTS.md next to that file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n…(truncated — reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file —\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton, SessionMoreMenu(session, onRemove) }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n // The per-chat \"...\" menu on a Workspaces/Collections row. Replaces the bare remove button (which\n // read like a more-actions affordance yet deleted on a single click) with an explicit menu.\n private Button SessionMoreMenu(ArchiveSession session, Action? onRemove)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 16, Glyph = \"\" } // More (...)\n };\n ToolTipService.SetToolTip(button, \"More actions\");\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false };\n\n var open = new MenuFlyoutItem { Text = \"Open\" };\n open.Click += (_, _) => OpenSession(session);\n flyout.Items.Add(open);\n\n var resume = new MenuFlyoutItem { Text = \"Resume in terminal\" };\n resume.Click += (_, _) => ResumeInTerminal(session);\n flyout.Items.Add(resume);\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n\n var rename = new MenuFlyoutItem { Text = \"Rename\" };\n rename.Click += async (_, _) => await RenameSessionByAsync(session);\n flyout.Items.Add(rename);\n\n var pin = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin\" : \"Pin to top\" };\n pin.Click += async (_, _) => { await _archive.TogglePinAsync(session); RenderCurrent(); };\n flyout.Items.Add(pin);\n\n var copyPath = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPath.Click += (_, _) => { SetClipboardText(session.SourcePath); SyncStatus.Text = \"Copied chat path.\"; };\n flyout.Items.Add(copyPath);\n\n if (onRemove is not null)\n {\n flyout.Items.Add(new MenuFlyoutSeparator());\n var remove = new MenuFlyoutItem { Text = \"Remove from collection\" };\n remove.Click += (_, _) => onRemove();\n flyout.Items.Add(remove);\n }\n\n flyout.Items.Add(new MenuFlyoutSeparator());\n var archive = new MenuFlyoutItem { Text = \"Archive (hide from lists)\" };\n archive.Click += async (_, _) => { await _archive.ArchiveSessionAsync(session); RenderCurrent(); };\n flyout.Items.Add(archive);\n\n button.Flyout = flyout;\n return button;\n }\n\n // Rename any chat (not just the selected one) - app-local custom title, persisted to the store.\n private async Task RenameSessionByAsync(ArchiveSession session)\n {\n var input = new TextBox { Text = session.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.RenameSessionAsync(session, input.Text);\n RenderCurrent();\n }\n }\n\n private static void SetClipboardText(string text)\n {\n var package = new DataPackage();\n package.SetText(text ?? \"\");\n Clipboard.SetContent(package);\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "31675a103ee17ca6dd6b907fcaf189fe480e1d92": {"t": 1785756811.7041662, "n": 75153, "h": "c393c26291c11a55", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n return;\n }\n\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(newButton, 1);\n header.Children.Add(newButton);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \"The line — set \\\"cwd\\\" to YOUR current working directory (forward slashes), which you already know:\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"target\\\":\\\"self\\\",\\\"cwd\\\":\\\"C:/your/current/working/dir\\\"}}\\n\" +\n \"How it resolves: the app re-scans and files the chat whose transcript is being written right \" +\n \"now in that folder (i.e. you) - so it always picks the live chat, not an old sibling. If you \" +\n \"truly don't know your cwd, omit it and the app uses the most recently active chat. Full \" +\n \"protocol: AGENTS.md next to that file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n…(truncated — reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file —\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n // Kill the Expander's own grey header/content chrome (the inner box that held the title) so the\n // black panel container shows through cleanly. Expander.Background doesn't cover these - they're\n // driven by theme resources, overridden per-instance here.\n var clear = new SolidColorBrush(Colors.Transparent);\n foreach (var key in new[]\n {\n \"ExpanderHeaderBackground\", \"ExpanderHeaderBorderBrush\",\n \"ExpanderHeaderPointerOverBackground\", \"ExpanderHeaderPressedBackground\",\n \"ExpanderHeaderDisabledBackground\", \"ExpanderContentBackground\", \"ExpanderContentBorderBrush\"\n })\n expander.Resources[key] = clear;\n expander.Resources[\"ExpanderHeaderBorderThickness\"] = new Thickness(0);\n\n // Keep the black panel container; only the inner grey box was the problem.\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n if (onRemove is not null)\n {\n var remove = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 13, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(remove, \"Remove from this collection (keeps the chat)\");\n remove.Click += (_, _) => onRemove();\n actions.Children.Add(remove);\n }\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "3d0d239875eb92c00b2771d4bb911fca17845d31": {"t": 1785756813.6727684, "n": 74432, "h": "471bcf15cbf4b5da", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n private bool _scrollArchiveToBottom; // jump to newest after the first render of a chat\n private bool _loadingOlder; // re-entrancy guard while prepending older messages on scroll-up\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start fresh and jump to the newest messages once it renders.\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; _scrollArchiveToBottom = true; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata). The reader shows the MOST RECENT messages at the bottom; scrolling up auto-loads\n // older ones, so a long chat opens where the conversation actually is.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n var start = messages.Count - shown; // render the last `shown` messages, oldest-of-page first\n\n if (start > 0)\n {\n MainContent.Children.Add(new TextBlock\n {\n Text = $\"Scroll up to load {start} earlier message{(start == 1 ? \"\" : \"s\")}\",\n Foreground = MutedBrush(),\n FontSize = 12,\n HorizontalAlignment = HorizontalAlignment.Center,\n Margin = new Thickness(0, 2, 0, 8)\n });\n }\n for (var i = start; i < messages.Count; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (_scrollArchiveToBottom && messages.Count > 0)\n {\n _scrollArchiveToBottom = false;\n // Force layout so ScrollableHeight is known, then jump to the newest message.\n MainScroller.UpdateLayout();\n MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null, disableAnimation: true);\n }\n }\n\n // Auto-paginate older messages when the user scrolls near the top - no manual \"load more\".\n private void MainScroller_ViewChanged(object sender, Microsoft.UI.Xaml.Controls.ScrollViewerViewChangedEventArgs e)\n {\n if (e.IsIntermediate || _loadingOlder) return;\n if (_screen != \"Archive\" || _selected is null || !_selected.ContentLoaded) return;\n if (MainScroller.VerticalOffset > 48) return; // only fire when near the top\n\n var count = _selected.Messages.Count;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, count);\n if (shown >= count) return; // nothing older to load\n\n _loadingOlder = true;\n try\n {\n var oldExtent = MainScroller.ExtentHeight;\n var oldOffset = MainScroller.VerticalOffset;\n _archiveShown = shown + ArchivePageSize;\n RenderArchive(); // re-renders with more older messages at the top\n MainScroller.UpdateLayout();\n // Keep the user's view anchored on the same message by absorbing the height added above.\n var delta = MainScroller.ExtentHeight - oldExtent;\n MainScroller.ChangeView(null, oldOffset + delta, null, disableAnimation: true);\n }\n finally { _loadingOlder = false; }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n return;\n }\n\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(newButton, 1);\n header.Children.Add(newButton);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \"The line — set \\\"cwd\\\" to YOUR current working directory (forward slashes), which you already know:\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"target\\\":\\\"self\\\",\\\"cwd\\\":\\\"C:/your/current/working/dir\\\"}}\\n\" +\n \"How it resolves: the app re-scans and files the chat whose transcript is being written right \" +\n \"now in that folder (i.e. you) - so it always picks the live chat, not an old sibling. If you \" +\n \"truly don't know your cwd, omit it and the app uses the most recently active chat. Full \" +\n \"protocol: AGENTS.md next to that file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n…(truncated — reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file —\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n // No surrounding panel box - the group sits flat on the page (just a hairline under the header\n // row), so Workspaces and Collections read as a clean list rather than nested grey cards.\n return new Border\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 0, 0, 1),\n Padding = new Thickness(2, 2, 2, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n if (onRemove is not null)\n {\n var remove = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 13, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(remove, \"Remove from this collection (keeps the chat)\");\n remove.Click += (_, _) => onRemove();\n actions.Children.Add(remove);\n }\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "c630be0c554f0481d9e4bc5c4cd284af3ccf9014": {"t": 1785756815.4054375, "n": 72192, "h": "a592824fd4e841a6", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start its pagination fresh (Load more keeps the same session, so it won't reset).\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata), then paginates so a huge transcript never renders all at once.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (shown < messages.Count)\n {\n var more = new Button\n {\n Content = $\"Load {Math.Min(ArchivePageSize, messages.Count - shown)} more ({messages.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _archiveShown = shown + ArchivePageSize; RenderArchive(); };\n MainContent.Children.Add(more);\n }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n return;\n }\n\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(newButton, 1);\n header.Children.Add(newButton);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add THIS chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \"The line — set \\\"cwd\\\" to YOUR current working directory (forward slashes), which you already know:\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"target\\\":\\\"self\\\",\\\"cwd\\\":\\\"C:/your/current/working/dir\\\"}}\\n\" +\n \"How it resolves: the app re-scans and files the chat whose transcript is being written right \" +\n \"now in that folder (i.e. you) - so it always picks the live chat, not an old sibling. If you \" +\n \"truly don't know your cwd, omit it and the app uses the most recently active chat. Full \" +\n \"protocol: AGENTS.md next to that file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n…(truncated — reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file —\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n if (onRemove is not null)\n {\n var remove = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 13, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(remove, \"Remove from this collection (keeps the chat)\");\n remove.Click += (_, _) => onRemove();\n actions.Children.Add(remove);\n }\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "afa3407bb842af986f26b5612945a07be1a4c29e": {"t": 1785756817.5172927, "n": 71975, "h": "32eb9397fd72fd02", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start its pagination fresh (Load more keeps the same session, so it won't reset).\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata), then paginates so a huge transcript never renders all at once.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (shown < messages.Count)\n {\n var more = new Button\n {\n Content = $\"Load {Math.Min(ArchivePageSize, messages.Count - shown)} more ({messages.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _archiveShown = shown + ArchivePageSize; RenderArchive(); };\n MainContent.Children.Add(more);\n }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Your hub - group chats into projects you can reopen\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(CollectionsControlBar());\n\n var collections = _archive.Store.Collections.Values\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n if (collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\",\n \"Make one with \\\"+ New collection\\\" above. Then add chats from a chat's right-click menu, or use a collection's \\\"Agent cmd\\\" button and paste it into any Claude/Codex chat to have it file itself in.\"));\n return;\n }\n\n foreach (var collection in collections)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name),\n onCopyAgentCommand: () => CopyCollectionAgentCommand(name),\n onRemoveSession: s => _ = RemoveSessionFromCollectionAsync(id, s.Id)));\n }\n }\n\n // The control panel header for Collections: create a project + explain the two ways chats get in.\n private Border CollectionsControlBar()\n {\n var newButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"+ New collection\"\n };\n newButton.Click += async (_, _) => await NewCollectionAsync();\n\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n header.Children.Add(new TextBlock\n {\n Text = \"Projects\",\n Foreground = StrongBrush(),\n FontSize = 16,\n FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(newButton, 1);\n header.Children.Add(newButton);\n\n var explain = new TextBlock\n {\n Foreground = MutedBrush(),\n TextWrapping = TextWrapping.Wrap,\n FontSize = 13,\n Text = \"A collection is a project folder for chats. Add chats from their right-click menu, or hit \\\"Agent cmd\\\" \" +\n \"on a collection and paste it into a chat so it files itself in. Expand a collection to open any chat or \" +\n \"resume it in a fresh terminal in its original folder - so you can close VS Code and pick work back up from here.\"\n };\n\n return Card(new StackPanel { Spacing = 12, Children = { header, explain } });\n }\n\n private async Task NewCollectionAsync()\n {\n var input = new TextBox\n {\n PlaceholderText = \"e.g. Renderer work, Job search, VENPOD\",\n MinWidth = 420,\n CornerRadius = ControlCornerRadius()\n };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Create\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n var name = input.Text.Trim();\n await _archive.CreateCollectionAsync(name);\n RenderCollections();\n SyncStatus.Text = $\"Created collection \\\"{name}\\\".\";\n }\n }\n\n private async Task RemoveSessionFromCollectionAsync(string collectionId, string sessionId)\n {\n await _archive.RemoveFromCollectionAsync(collectionId, sessionId);\n RenderCollections();\n }\n\n private void CopyCollectionAgentCommand(string name)\n {\n var package = new DataPackage();\n package.SetText(CollectionAgentInstruction(name));\n Clipboard.SetContent(package);\n SyncStatus.Text = $\"Copied the agent command for \\\"{name}\\\" - paste it into a chat.\";\n }\n\n // The self-file instruction a user pastes into any Claude/Codex chat. The agent appends one line\n // to the inbox the app already polls; \"self\" resolves to the latest chat from that working folder.\n private static string CollectionAgentInstruction(string projectName)\n {\n var inbox = System.IO.Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\", \"agent-inbox.jsonl\");\n return\n $\"Add this chat to my \\\"Codex Local Retrieval\\\" app under the project \\\"{projectName}\\\".\\n\" +\n \"Append exactly one line (then a newline) to this file:\\n\" +\n $\" {inbox}\\n\" +\n \"The line to append (use FORWARD slashes in the path, replace it with your real working directory):\\n\" +\n $\" {{\\\"op\\\":\\\"addToCollection\\\",\\\"project\\\":\\\"{projectName}\\\",\\\"target\\\":\\\"self\\\",\\\"cwd\\\":\\\"C:/path/to/this/project\\\"}}\\n\" +\n $\"The app polls that file every couple seconds and files this chat under \\\"{projectName}\\\". \" +\n \"Full protocol: AGENTS.md next to that file.\";\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n…(truncated — reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file —\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action? onRemoveSession = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats yet - add some from a chat's right-click menu, or paste this collection's Agent cmd into a chat.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList)\n stack.Children.Add(SessionRow(session, onRemoveSession is null ? null : () => onRemoveSession(session)));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onCopyAgentCommand is not null)\n {\n var agentCmd = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(10, 0, 10, 0),\n MinHeight = 32,\n Height = 32,\n Content = new TextBlock { Text = \"Agent cmd\", FontSize = 12 }\n };\n ToolTipService.SetToolTip(agentCmd, \"Copy a command to paste into a chat so it files itself into this collection\");\n agentCmd.Click += (_, _) => onCopyAgentCommand();\n rightActions.Children.Add(agentCmd);\n }\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session, Action? onRemove = null)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n if (onRemove is not null)\n {\n var remove = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 34,\n Height = 34,\n MinWidth = 34,\n MinHeight = 34,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 13, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(remove, \"Remove from this collection (keeps the chat)\");\n remove.Click += (_, _) => onRemove();\n actions.Children.Add(remove);\n }\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "9160c3faef213416b62a02554190f853c2247bc1": {"t": 1785756819.4127154, "n": 65834, "h": "cec2406c38279513", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n\n private int _searchShown;\n private string _lastSearchQuery = \"\";\n private const int SearchPageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start its pagination fresh (Load more keeps the same session, so it won't reset).\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata), then paginates so a huge transcript never renders all at once.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (shown < messages.Count)\n {\n var more = new Button\n {\n Content = $\"Load {Math.Min(ArchivePageSize, messages.Count - shown)} more ({messages.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _archiveShown = shown + ArchivePageSize; RenderArchive(); };\n MainContent.Children.Add(more);\n }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n // New query -> start paging fresh (Show more keeps the same query, so it won't reset).\n if (!string.Equals(_deepSearchQuery, _lastSearchQuery, StringComparison.Ordinal)) { _searchShown = 0; _lastSearchQuery = _deepSearchQuery; }\n var shown = Math.Min(_searchShown <= 0 ? SearchPageSize : _searchShown, hits.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(SearchHitResult(hits[i]));\n\n if (shown < hits.Count)\n {\n var more = new Button\n {\n Content = $\"Show {Math.Min(SearchPageSize, hits.Count - shown)} more ({hits.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _searchShown = shown + SearchPageSize; RenderSearch(query); };\n MainContent.Children.Add(more);\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name)));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No projects yet\", \"Right-click a chat and choose \\\"Add to collection\\\" to start one.\"));\n }\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n // Read + parse the rollout OFF the UI thread (up to 400 lines) so opening Source never hitches.\n MainContent.Children.Add(EmptyBlock(\"Reading source...\", _selected.WorkspaceName));\n _ = LoadSourceThenRenderAsync(_selected);\n }\n\n private async Task LoadSourceThenRenderAsync(ArchiveSession session)\n {\n IReadOnlyList events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty(); }\n if (!ReferenceEquals(_selected, session) || _screen != \"Source\") return;\n MainContent.Children.Clear();\n MainContent.Children.Add(InfoPanel(\"Source file\", session.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(session, events));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n…(truncated — reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file —\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions, Action? onDelete = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush(), Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList) stack.Children.Add(SessionRow(session));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private async void Copy(string mode)\n {\n if (_selected is null) return;\n var session = _selected;\n if (mode is not (\"path\" or \"paths\")) await _archive.EnsureContentAsync(session); // off-thread, no UI block\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout { AreOpenCloseAnimationsEnabled = false }; // snap open instantly (no fade-in lag)\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "90d186f23c360affd5a0dc40d2f1adb327fd27ad": {"t": 1785756821.1417677, "n": 63924, "h": "27f98fac3070e56d", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 25;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start its pagination fresh (Load more keeps the same session, so it won't reset).\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata), then paginates so a huge transcript never renders all at once.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (shown < messages.Count)\n {\n var more = new Button\n {\n Content = $\"Load {Math.Min(ArchivePageSize, messages.Count - shown)} more ({messages.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _archiveShown = shown + ArchivePageSize; RenderArchive(); };\n MainContent.Children.Add(more);\n }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name)));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No projects yet\", \"Right-click a chat and choose \\\"Add to collection\\\" to start one.\"));\n }\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(_selected));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private static string CapDisplay(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n\\n…(truncated — reopen the chat to see the full message)\";\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n // A single message can carry a 500KB tool dump; laying that out in a wrapping TextBlock is what\n // made opening a chat hitch. Cap the DISPLAYED text (full content stays in the source file —\n // \"Resume in terminal\" / \"Open in VS Code\" shows it all).\n Text = CapDisplay(CleanReadingText(message.Text), 4000),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = CapDisplay(block.Code, 4000), Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions, Action? onDelete = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush(), Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList) stack.Children.Add(SessionRow(session));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "eae5e0cf9f6b3676162ea2101a70f7529502bc97": {"t": 1785756822.6995327, "n": 63412, "h": "1ae3d4d33c256752", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private int _archiveShown;\n private ArchiveSession? _lastArchiveSession;\n private const int ArchivePageSize = 60;\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n // New chat selected -> start its pagination fresh (Load more keeps the same session, so it won't reset).\n if (!ReferenceEquals(_selected, _lastArchiveSession)) { _archiveShown = 0; _lastArchiveSession = _selected; }\n\n // Content lazy-loads from the source file the first time you open a chat (the store holds only\n // metadata), then paginates so a huge transcript never renders all at once.\n if (!_selected.ContentLoaded)\n {\n MainContent.Children.Add(EmptyBlock(\"Loading conversation...\", _selected.WorkspaceName));\n _ = EnsureContentThenRenderAsync(_selected);\n return;\n }\n\n var messages = _selected.Messages;\n var shown = Math.Min(_archiveShown <= 0 ? ArchivePageSize : _archiveShown, messages.Count);\n for (var i = 0; i < shown; i++) MainContent.Children.Add(MessageBubble(messages[i]));\n\n if (shown < messages.Count)\n {\n var more = new Button\n {\n Content = $\"Load {Math.Min(ArchivePageSize, messages.Count - shown)} more ({messages.Count - shown} left)\",\n Margin = new Thickness(0, 8, 0, 16),\n HorizontalAlignment = HorizontalAlignment.Center\n };\n more.Click += (_, _) => { _archiveShown = shown + ArchivePageSize; RenderArchive(); };\n MainContent.Children.Add(more);\n }\n }\n\n private async Task EnsureContentThenRenderAsync(ArchiveSession session)\n {\n try { await _archive.EnsureContentAsync(session); }\n catch (Exception ex) { Diag.Log(\"EnsureContent: \" + ex.Message); }\n if (ReferenceEquals(_selected, session) && _screen == \"Archive\")\n {\n _archiveShown = ArchivePageSize;\n RenderArchive();\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name)));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No projects yet\", \"Right-click a chat and choose \\\"Add to collection\\\" to start one.\"));\n }\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(_selected));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n Text = CleanReadingText(message.Text),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = block.Code, Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions, Action? onDelete = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush(), Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList) stack.Children.Add(SessionRow(session));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "24d2afb65f2228c43d6def46a9d8692e2bb2abb5": {"t": 1785756824.4087255, "n": 61636, "h": "36720af0919f8679", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n Diag.Log(\"MP.Loaded: list bound\");\n SelectFirstSession();\n Diag.Log(\"MP.Loaded: first selected\");\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n foreach (var message in _selected.Messages.Take(80))\n {\n MainContent.Children.Add(MessageBubble(message));\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name)));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No projects yet\", \"Right-click a chat and choose \\\"Add to collection\\\" to start one.\"));\n }\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(_selected));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n Text = CleanReadingText(message.Text),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = block.Code, Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions, Action? onDelete = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush(), Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList) stack.Children.Add(SessionRow(session));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "08fedd513748c2ec7a8b540886b6d15b0b3d5426": {"t": 1785756826.0245185, "n": 61538, "h": "b89b99fe29575534", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n SelectFirstSession();\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderCopilot();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n foreach (var message in _selected.Messages.Take(80))\n {\n MainContent.Children.Add(MessageBubble(message));\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name)));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No projects yet\", \"Right-click a chat and choose \\\"Add to collection\\\" to start one.\"));\n }\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(_selected));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n Text = CleanReadingText(message.Text),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = block.Code, Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions, Action? onDelete = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush(), Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList) stack.Children.Add(SessionRow(session));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "4005f3e40369379f793c0766652793f2bc9d1fb1": {"t": 1785756827.8260124, "n": 61534, "h": "1cffab1774399d2a", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n SelectFirstSession();\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n Diag.Log(\"DeepSeek key source: \" + ApiKeySource(\"deepseek\"));\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderAsk();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n foreach (var message in _selected.Messages.Take(80))\n {\n MainContent.Children.Add(MessageBubble(message));\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name)));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No projects yet\", \"Right-click a chat and choose \\\"Add to collection\\\" to start one.\"));\n }\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(_selected));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n Text = CleanReadingText(message.Text),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = block.Code, Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions, Action? onDelete = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush(), Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList) stack.Children.Add(SessionRow(session));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n // Autograb from the environment FIRST (the \"set it once\" canonical source the user asked\n // for); fall back to a key saved in the app's credential vault if no env var is set.\n var env = EnvApiKey(providerId);\n if (!string.IsNullOrWhiteSpace(env)) return env;\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n // Autograb: read the key from a standard env var so it can be set once\n // (DEEPSEEK_API_KEY / OPENAI_API_KEY / else CLR__API_KEY) and never pasted into the app or\n // written to the store. A key saved in the credential vault still takes precedence.\n private static string EnvApiKey(string providerId)\n {\n string[] names = providerId.ToLowerInvariant() switch\n {\n \"deepseek\" => new[] { \"DEEPSEEK_API_KEY\" },\n \"openai\" => new[] { \"OPENAI_API_KEY\" },\n _ => new[] { $\"CLR_{providerId.ToUpperInvariant()}_API_KEY\" }\n };\n foreach (var name in names)\n {\n var value = Environment.GetEnvironmentVariable(name);\n if (!string.IsNullOrWhiteSpace(value)) return value.Trim();\n }\n return \"\";\n }\n\n public static string ApiKeySource(string providerId)\n {\n if (!string.IsNullOrWhiteSpace(EnvApiKey(providerId))) return \"environment variable\";\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n if (!string.IsNullOrWhiteSpace(credential.Password)) return \"saved in app\";\n }\n catch { }\n return \"none\";\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "f872273b88229ab943352fa808e43c5ff87d23a9": {"t": 1785756829.715259, "n": 59847, "h": "8f53c7ef5935466f", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n SelectFirstSession();\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderAsk();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n foreach (var message in _selected.Messages.Take(80))\n {\n MainContent.Children.Add(MessageBubble(message));\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n var id = collection.Id;\n var name = collection.Name;\n MainContent.Children.Add(ExpandableSessionGroup(name, $\"{sessions.Count} chats\", sessions,\n onDelete: () => _ = DeleteCollectionAsync(id, name)));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No projects yet\", \"Right-click a chat and choose \\\"Add to collection\\\" to start one.\"));\n }\n }\n\n private async Task DeleteCollectionAsync(string id, string name)\n {\n var dialog = new ContentDialog\n {\n Title = \"Delete project\",\n Content = $\"Remove the \\\"{name}\\\" project? The chats themselves stay in your archive — only the grouping is removed.\",\n PrimaryButtonText = \"Delete\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Close,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RemoveCollectionAsync(id);\n RenderCollections();\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(_selected));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n Text = CleanReadingText(message.Text),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = block.Code, Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions, Action? onDelete = null)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush(), Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList) stack.Children.Add(SessionRow(session));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n\n var rightActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n rightActions.Children.Add(new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n });\n if (onDelete is not null)\n {\n var delete = new Button\n {\n Style = (Style)Resources[\"IconButtonStyle\"],\n Width = 32,\n Height = 32,\n MinWidth = 32,\n MinHeight = 32,\n Content = new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 14, Glyph = \"\" }\n };\n ToolTipService.SetToolTip(delete, \"Delete project (keeps the chats)\");\n delete.Click += (_, _) => onDelete();\n rightActions.Children.Add(delete);\n }\n Grid.SetColumn(rightActions, 1);\n header.Children.Add(rightActions);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "6e18e56ddecf7e1579ca8952da6a59aa84a9615c": {"t": 1785756831.1492496, "n": 58237, "h": "8c3ec2cf22c8597d", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n Diag.Log(\"MP.ctor: before InitializeComponent\");\n InitializeComponent();\n Diag.Log(\"MP.ctor: after InitializeComponent\");\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n Diag.Log(\"MP.Loaded: start\");\n try\n {\n await _archive.LoadAsync();\n Diag.Log(\"MP.Loaded: archive loaded (\" + _archive.Sessions.Count + \" sessions)\");\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n SelectFirstSession();\n RenderCurrent();\n Diag.Log(\"MP.Loaded: render done\");\n StartCaptureHarness();\n StartAgentBridge();\n _ = StartupResurfaceAsync();\n }\n catch (Exception ex)\n {\n Diag.Log(\"MP.Loaded: EXCEPTION \" + ex);\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderAsk();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateChrome();\n UpdateBackButton();\n }\n\n // The right panel (quick actions / tags) and the header actions (copy context /\n // build restore packet) are session-specific — they only belong on screens tied to\n // the selected chat. Hide them elsewhere and reclaim the space so each screen shows\n // only what's relevant.\n private void UpdateChrome()\n {\n bool sessionContext = _screen is \"Archive\" or \"Source\" or \"Restore\";\n RightColumnBorder.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n RightColumn.Width = sessionContext ? new GridLength(292) : new GridLength(0);\n HeaderActions.Visibility = sessionContext ? Visibility.Visible : Visibility.Collapsed;\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private void RenderArchive()\n {\n ScreenLabel.Text = _selected is null\n ? \"Archive reader\"\n : $\"{(string.Equals(_selected.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} chat - archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n foreach (var message in _selected.Messages.Take(80))\n {\n MainContent.Children.Add(MessageBubble(message));\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n // Snapshot: a background sync may still be writing Store.Sessions on launch.\n var groups = _archive.Store.Sessions.Values.ToList()\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n MainContent.Children.Add(ExpandableSessionGroup(collection.Name, $\"{sessions.Count} chats\", sessions));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\", \"Collections are native app metadata only.\"));\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AgentAccessPanel());\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw rollout timeline\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chat selected\", \"Pick a chat to inspect its raw events.\"));\n return;\n }\n MainContent.Children.Add(InfoPanel(\"Source file\", _selected.SourcePath));\n MainContent.Children.Add(SourceEventsPanel(_selected));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n Text = CleanReadingText(message.Text),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22,\n FontSize = 14\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = block.Code, Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private UIElement ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions)\n {\n var sessionList = sessions.Take(120).ToList();\n var stack = new StackPanel { Spacing = 0 };\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush(), Margin = new Thickness(0, 6, 0, 0) });\n }\n foreach (var session in sessionList) stack.Children.Add(SessionRow(session));\n\n // Dense header: name + count pill on one line, path muted underneath. No oversized type.\n var header = new Grid { ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n var titleRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n titleRow.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis });\n header.Children.Add(titleRow);\n var count = new Border\n {\n Background = AccentVerySoftBrush(),\n CornerRadius = new CornerRadius(9),\n Padding = new Thickness(9, 1, 9, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = subtitle, Foreground = MutedBrush(), FontSize = 11 }\n };\n Grid.SetColumn(count, 1);\n header.Children.Add(count);\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n HorizontalContentAlignment = HorizontalAlignment.Stretch,\n Header = header,\n Content = stack\n };\n\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(14, 6, 14, 6),\n Child = expander\n };\n }\n\n // A compact colored badge naming the agent that produced a chat (claude / codex).\n private Border ToolBadge(string tool)\n {\n var claude = string.Equals(tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var tint = claude\n ? Windows.UI.Color.FromArgb(40, 214, 153, 92) // warm = claude\n : Windows.UI.Color.FromArgb(40, 120, 170, 255); // cool = codex\n return new Border\n {\n Background = new SolidColorBrush(tint),\n CornerRadius = new CornerRadius(5),\n Padding = new Thickness(6, 1, 6, 1),\n VerticalAlignment = VerticalAlignment.Center,\n Child = new TextBlock { Text = claude ? \"claude\" : \"codex\", Foreground = StrongBrush(), FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }\n };\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var resumeButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Resume\"\n };\n resumeButton.Click += (_, _) => ResumeInTerminal(session);\n\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n MinHeight = 34,\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var grid = new Grid\n {\n MinHeight = 40,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n\n var meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, VerticalAlignment = VerticalAlignment.Center };\n meta.Children.Add(ToolBadge(session.Tool));\n meta.Children.Add(new TextBlock { Text = session.DisplayDate, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center });\n\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n VerticalAlignment = VerticalAlignment.Center,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },\n meta\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 6,\n VerticalAlignment = VerticalAlignment.Center,\n Children = { resumeButton, openButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 8, 0, 8),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n // Cap the measure so label/control pairs stay visually associated instead of being\n // flung to opposite edges of a wide panel (the \"far-right gap\" the dashboard would have).\n var stack = new StackPanel\n {\n Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14,\n MaxWidth = 660,\n HorizontalAlignment = HorizontalAlignment.Left\n };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n\n // Reading-mode cleanup (logic lives in Core.ArchiveService.ForReading so it's unit-tested):\n // drop fenced code + machine-context noise; show a marker when a message was pure context.\n private static string CleanReadingText(string text)\n {\n var value = ArchiveService.ForReading(text);\n return value.Length == 0 ? \"(IDE / environment context)\" : value;\n }\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "8e390fa1e53063d2e9d131ca0d98f69a5db963ef": {"t": 1785756832.8198197, "n": 58590, "h": "f5e4e251dfc0ba32", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n private string _indexStatus = \"\";\n\n public MainPage()\n {\n InitializeComponent();\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n await _archive.LoadAsync();\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n SelectFirstSession();\n RenderCurrent();\n _ = AutoIndexAfterFirstPaint();\n _ = RefreshTitlesAfterFirstPaint();\n }\n\n private async Task AutoIndexAfterFirstPaint()\n {\n await Task.Delay(250);\n var progress = new Progress(message =>\n {\n _indexStatus = message;\n if (_screen == \"Settings\")\n {\n RenderSettings();\n }\n });\n\n var indexed = await _archive.AutoIndexAsync(progress);\n if (indexed > 0)\n {\n SelectFirstSession();\n RenderCurrent();\n _ = RefreshTitlesAfterFirstPaint();\n }\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderAsk();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateBackButton();\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private void RenderArchive()\n {\n ScreenLabel.Text = \"Archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n foreach (var message in _selected.Messages.Take(80))\n {\n MainContent.Children.Add(MessageBubble(message));\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n var groups = _archive.Store.Sessions.Values\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n MainContent.Children.Add(ExpandableSessionGroup(collection.Name, $\"{sessions.Count} chats\", sessions));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\", \"Collections are native app metadata only.\"));\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(ChatSourcePanel());\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw source\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(InfoPanel(\"Source path\", _selected.SourcePath));\n MainContent.Children.Add(InfoPanel(\"Workspace\", _selected.Workspace));\n MainContent.Children.Add(InfoPanel(\"Parser mode\", \"Native index preview. Open the source file for full raw history.\"));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n Text = StripCode(message.Text),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = block.Code, Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private Border ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions)\n {\n var stack = new StackPanel { Spacing = 8 };\n var sessionList = sessions.Take(120).ToList();\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush() });\n }\n foreach (var session in sessionList)\n {\n stack.Children.Add(SessionRow(session));\n }\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Header = new StackPanel\n {\n Spacing = 4,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = subtitle, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n },\n Content = stack\n };\n\n return Card(expander);\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var copyButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n Content = \"Copy path\"\n };\n copyButton.Click += (_, _) => CopyPath(session);\n\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.DisplayDate} - {session.SourcePath}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap }\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 10, 0, 0),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border ChatSourcePanel()\n {\n var pathBox = new TextBox\n {\n Text = _archive.Store.Settings.ChatRootPath,\n PlaceholderText = @\"%USERPROFILE%\\.codex\\sessions\",\n MinWidth = 520,\n CornerRadius = ControlCornerRadius()\n };\n\n var autoCheck = new CheckBox\n {\n Content = \"Auto-index on startup\",\n IsChecked = _archive.Store.Settings.AutoIndexOnStartup,\n Foreground = StrongBrush()\n };\n autoCheck.Checked += async (_, _) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.AutoIndexOnStartup = true;\n await _archive.SaveAsync();\n };\n autoCheck.Unchecked += async (_, _) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.AutoIndexOnStartup = false;\n await _archive.SaveAsync();\n };\n\n var detectButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Auto-detect\" };\n detectButton.Click += async (_, _) =>\n {\n var detected = _archive.ResolveDefaultChatRoot();\n if (string.IsNullOrWhiteSpace(detected))\n {\n await ShowInfoAsync(\"No local sessions found\", \"Set the folder that contains your Codex JSONL chat files, usually .codex\\\\sessions.\");\n return;\n }\n\n pathBox.Text = detected;\n await IndexChatRootAsync(detected);\n };\n\n var indexButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Index folder\" };\n indexButton.Click += async (_, _) => await IndexChatRootAsync(pathBox.Text);\n\n var candidates = _archive.CandidateChatRoots()\n .Where(Directory.Exists)\n .Take(4)\n .ToList();\n var candidatesText = candidates.Count == 0\n ? \"No default Codex folders found yet.\"\n : \"Detected: \" + string.Join(\" | \", candidates);\n\n var statusText = string.IsNullOrWhiteSpace(_indexStatus)\n ? _archive.Store.Settings.LastIndexStatus\n : _indexStatus;\n\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"Chat source\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Point the app at your local Codex sessions folder. Source files are read-only; the app stores its own index and metadata separately.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(pathBox);\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { indexButton, detectButton, autoCheck }\n });\n stack.Children.Add(new TextBlock { Text = candidatesText, Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap });\n if (!string.IsNullOrWhiteSpace(statusText))\n {\n stack.Children.Add(new TextBlock { Text = statusText, Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap });\n }\n\n return Card(stack);\n }\n\n private async Task IndexChatRootAsync(string rootPath)\n {\n rootPath = Environment.ExpandEnvironmentVariables(rootPath.Trim());\n if (string.IsNullOrWhiteSpace(rootPath) || !Directory.Exists(rootPath))\n {\n await ShowInfoAsync(\"Folder not found\", \"Enter an existing folder that contains Codex chat JSONL files.\");\n return;\n }\n\n _indexStatus = $\"Indexing {rootPath}\";\n RenderSettings();\n var progress = new Progress(message =>\n {\n _indexStatus = message;\n if (_screen == \"Settings\")\n {\n RenderSettings();\n }\n });\n\n var indexed = await _archive.IndexRootAsync(rootPath, progress);\n if (indexed > 0)\n {\n await _archive.EnrichTitlesFromLocalStateAsync();\n SelectFirstSession();\n }\n RenderCurrent();\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}, "d41198d2e972338b644526073b7267545985d5a8": {"t": 1785756834.5411727, "n": 53692, "h": "c67be4e1b6c5fafe", "c": "using System.Collections.ObjectModel;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\nusing Windows.ApplicationModel.DataTransfer;\nusing Windows.Security.Credentials;\n\nnamespace CodexLocalRetrieval_Native;\n\npublic sealed partial class MainPage : Page\n{\n private readonly ArchiveService _archive = new();\n private readonly AiChatService _ai = new();\n private readonly Stack _backStack = new();\n private ArchiveSession? _selected;\n private string _screen = \"Archive\";\n private Windows.UI.Color _accentColor = Windows.UI.Color.FromArgb(255, 251, 113, 133);\n private int _panelRadius = 12;\n private int _controlRadius = 12;\n private bool _storeLoaded;\n private string _deepSearchQuery = \"\";\n private string _askQuestion = \"\";\n private string _askAnswer = \"\";\n private string _askStatus = \"Configure a provider in Settings, then ask a question about your archive.\";\n\n public MainPage()\n {\n InitializeComponent();\n Loaded += MainPage_Loaded;\n }\n\n private async void MainPage_Loaded(object sender, RoutedEventArgs e)\n {\n await _archive.LoadAsync();\n ApplyThemeAndShape();\n _storeLoaded = true;\n SessionList.ItemsSource = _archive.Sessions;\n SelectFirstSession();\n RenderCurrent();\n _ = RefreshTitlesAfterFirstPaint();\n }\n\n private async Task RefreshTitlesAfterFirstPaint()\n {\n try\n {\n if (await _archive.EnrichTitlesFromLocalStateAsync())\n {\n SelectFirstSession();\n RenderCurrent();\n }\n }\n catch\n {\n // Local title indexes are optional; startup should not depend on them.\n }\n }\n\n private void SelectFirstSession()\n {\n _selected = _archive.Sessions.FirstOrDefault();\n SessionList.SelectedItem = _selected;\n }\n\n private void SessionList_ItemClick(object sender, ItemClickEventArgs e)\n {\n _selected = e.ClickedItem as ArchiveSession;\n Navigate(\"Archive\");\n }\n\n private void SearchBox_KeyDown(object sender, KeyRoutedEventArgs e)\n {\n if (e.Key == Windows.System.VirtualKey.Enter)\n {\n ApplySearch(SearchBox.Text);\n }\n }\n\n private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)\n {\n if (SearchBox.Text.Length == 0)\n {\n _archive.RefreshSessions(_archive.Search(\"\"));\n SelectFirstSession();\n RenderCurrent();\n }\n }\n\n private void ApplySearch(string query)\n {\n var results = _archive.Search(query);\n _archive.RefreshSessions(results);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private void Nav_Click(object sender, RoutedEventArgs e)\n {\n if (sender is not Button button || button.Tag is not string target) return;\n Navigate(target);\n }\n\n private void Navigate(string target, bool pushHistory = true)\n {\n if (pushHistory && _screen != target)\n {\n _backStack.Push(_screen);\n }\n\n _screen = target;\n RenderCurrent();\n }\n\n private void RenderCurrent()\n {\n switch (_screen)\n {\n case \"Search\":\n RenderSearch(SearchBox.Text);\n break;\n case \"Ask\":\n RenderAsk();\n break;\n case \"Workspaces\":\n RenderWorkspaces();\n break;\n case \"Collections\":\n RenderCollections();\n break;\n case \"Settings\":\n RenderSettings();\n break;\n case \"Source\":\n RenderSource();\n break;\n case \"Restore\":\n RenderRestore();\n break;\n default:\n RenderArchive();\n break;\n }\n UpdateBackButton();\n }\n\n private void Back_Click(object sender, RoutedEventArgs e)\n {\n if (_backStack.Count == 0) return;\n Navigate(_backStack.Pop(), pushHistory: false);\n }\n\n private void UpdateBackButton()\n {\n BackButton.IsEnabled = _backStack.Count > 0;\n BackButton.Opacity = _backStack.Count > 0 ? 1 : 0.45;\n }\n\n private void RenderArchive()\n {\n ScreenLabel.Text = \"Archive reader\";\n TitleText.Text = _selected?.DisplayTitle ?? \"No chat selected\";\n MainContent.Children.Clear();\n RenderTags();\n\n if (_selected is null)\n {\n MainContent.Children.Add(EmptyBlock(\"No chats indexed\", \"Import a sessions folder to begin.\"));\n return;\n }\n\n foreach (var message in _selected.Messages.Take(80))\n {\n MainContent.Children.Add(MessageBubble(message));\n }\n }\n\n private void RenderSearch(string query)\n {\n ScreenLabel.Text = \"Deep content retrieval\";\n TitleText.Text = \"Global search\";\n MainContent.Children.Clear();\n\n MainContent.Children.Add(DeepSearchPanel());\n var hits = _archive.DeepSearch(_deepSearchQuery);\n if (hits.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No deep matches\", \"Try fewer words, a rough phrase, a file name, or a path fragment.\"));\n return;\n }\n\n foreach (var hit in hits)\n {\n MainContent.Children.Add(SearchHitResult(hit));\n }\n }\n\n private void RenderWorkspaces()\n {\n ScreenLabel.Text = \"Grouped by local project path\";\n TitleText.Text = \"Workspaces\";\n MainContent.Children.Clear();\n var groups = _archive.Store.Sessions.Values\n .GroupBy(s => string.IsNullOrWhiteSpace(s.WorkspaceName) ? \"Unknown\" : s.WorkspaceName)\n .OrderByDescending(g => g.Max(s => s.UpdatedAt));\n foreach (var group in groups.Take(80))\n {\n MainContent.Children.Add(ExpandableSessionGroup(\n group.Key,\n $\"{group.Count()} chats - {group.First().Workspace}\",\n group.OrderByDescending(session => session.Pinned).ThenByDescending(session => session.UpdatedAt)));\n }\n }\n\n private void RenderCollections()\n {\n ScreenLabel.Text = \"Manual organization\";\n TitleText.Text = \"Collections\";\n MainContent.Children.Clear();\n foreach (var collection in _archive.Store.Collections.Values)\n {\n var sessions = collection.SessionIds\n .Select(id => _archive.Store.Sessions.TryGetValue(id, out var session) ? session : null)\n .OfType()\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n MainContent.Children.Add(ExpandableSessionGroup(collection.Name, $\"{sessions.Count} chats\", sessions));\n }\n if (_archive.Store.Collections.Count == 0)\n {\n MainContent.Children.Add(EmptyBlock(\"No collections yet\", \"Collections are native app metadata only.\"));\n }\n }\n\n private void RenderSettings()\n {\n ScreenLabel.Text = \"Preferences and safety\";\n TitleText.Text = \"Settings\";\n MainContent.Children.Clear();\n MainContent.Children.Add(SettingsPanel(\n \"Theme picker\",\n \"Choose the palette, accent, shape, and density used by the app.\",\n SettingControlRow(\"Palette\", ThemeCombo()),\n SettingControlRow(\"Accent preset\", AccentCombo()),\n SettingControlRow(\"Custom accent\", AccentColorPicker()),\n SettingControlRow(\"Shape\", ShapeCombo()),\n SettingControlRow(\"Density\", DensityCombo())));\n MainContent.Children.Add(AiProviderPanel());\n MainContent.Children.Add(SettingRow(\"Read-only source mode\", _archive.Store.Settings.ReadOnlySourceMode ? \"On\" : \"Off\"));\n }\n\n private void RenderAsk()\n {\n ScreenLabel.Text = \"Retrieval assisted answers\";\n TitleText.Text = \"Ask archive\";\n MainContent.Children.Clear();\n MainContent.Children.Add(AskPanel());\n if (!string.IsNullOrWhiteSpace(_askAnswer))\n {\n MainContent.Children.Add(TextPanel(_askAnswer));\n }\n }\n\n private void RenderSource()\n {\n ScreenLabel.Text = \"Raw source\";\n TitleText.Text = \"Source inspector\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(InfoPanel(\"Source path\", _selected.SourcePath));\n MainContent.Children.Add(InfoPanel(\"Workspace\", _selected.Workspace));\n MainContent.Children.Add(InfoPanel(\"Parser mode\", \"Native index preview. Open the source file for full raw history.\"));\n }\n\n private void RenderRestore()\n {\n ScreenLabel.Text = \"New chat handoff\";\n TitleText.Text = \"Restore packet\";\n MainContent.Children.Clear();\n if (_selected is null) return;\n MainContent.Children.Add(TextPanel(_archive.RestorePacket(_selected)));\n }\n\n private void RenderTags()\n {\n TagsItems.Children.Clear();\n if (_selected is null) return;\n\n var tags = _selected.Tags\n .Where(tag => !string.IsNullOrWhiteSpace(tag))\n .Where(tag => !string.Equals(tag, \"archive\", StringComparison.OrdinalIgnoreCase) || _selected.Tags.Count == 1)\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (tags.Count == 0)\n {\n TagsItems.Children.Add(new TextBlock\n {\n Text = \"No tags\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n return;\n }\n\n foreach (var tag in tags)\n {\n TagsItems.Children.Add(TagChip(tag));\n }\n }\n\n private Border TagChip(string tag)\n {\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(0, 0, 0, 0)),\n BorderBrush = AccentSoftBrush(),\n BorderThickness = new Thickness(2, 0, 0, 0),\n CornerRadius = new CornerRadius(0),\n Padding = new Thickness(8, 2, 10, 2),\n Margin = new Thickness(0, 0, 10, 8),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new TextBlock\n {\n Text = tag,\n Foreground = MutedBrush(),\n FontSize = 12,\n VerticalAlignment = VerticalAlignment.Center\n }\n };\n }\n\n private Border MessageBubble(ArchiveMessage message)\n {\n var stack = new StackPanel { Spacing = 10 };\n stack.Children.Add(new TextBlock\n {\n Text = $\"{message.RoleLabel} - {FormatDate(message.Timestamp)}\",\n Foreground = MutedBrush(),\n FontSize = 12\n });\n stack.Children.Add(new TextBlock\n {\n Text = StripCode(message.Text),\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n LineHeight = 22\n });\n foreach (var block in message.CodeBlocks)\n {\n stack.Children.Add(CodeBlockPanel(block));\n }\n\n return new Border\n {\n Background = message.Role == \"user\" ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n MaxWidth = 860,\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = stack\n };\n }\n\n private UIElement CodeBlockPanel(CodeBlock block)\n {\n return new Border\n {\n Background = new SolidColorBrush(Colors.Black),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = ControlCornerRadius(),\n Padding = new Thickness(14),\n Child = new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = block.Language, Foreground = MutedBrush(), FontSize = 12 },\n new TextBlock { Text = block.Code, Foreground = StrongBrush(), FontFamily = new FontFamily(\"Consolas\"), TextWrapping = TextWrapping.Wrap }\n }\n }\n };\n }\n\n private Border SessionResult(ArchiveSession session)\n {\n var button = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open\",\n HorizontalAlignment = HorizontalAlignment.Left,\n Tag = session\n };\n button.Click += (_, _) =>\n {\n OpenSession(session);\n };\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.WorkspaceName} - {session.SourcePath}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n button\n }\n });\n }\n\n private Border SearchHitResult(ArchiveSearchHit hit)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Open chat\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n openButton.Click += (_, _) => OpenSession(hit.Session);\n\n var copyPathButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Copy path\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n copyPathButton.Click += (_, _) => CopyPath(hit.Session);\n\n return Card(new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = hit.Session.DisplayTitle, Foreground = StrongBrush(), FontSize = 17, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{hit.SourceLabel} - score {hit.Score} - {hit.MatchedTerms}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = hit.Snippet, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 21 },\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyPathButton }\n }\n }\n });\n }\n\n private Border DeepSearchPanel()\n {\n var input = new TextBox\n {\n Text = _deepSearchQuery,\n PlaceholderText = \"Search inside every conversation with loose matching\",\n CornerRadius = ControlCornerRadius(),\n MinWidth = 360\n };\n input.KeyDown += (_, args) =>\n {\n if (args.Key != Windows.System.VirtualKey.Enter) return;\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n input.TextChanged += (_, _) =>\n {\n if (input.Text.Length != 0) return;\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var button = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Search content\"\n };\n button.Click += (_, _) =>\n {\n _deepSearchQuery = input.Text;\n RenderSearch(_deepSearchQuery);\n };\n\n var clearButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Clear\"\n };\n clearButton.Click += (_, _) =>\n {\n _deepSearchQuery = \"\";\n RenderSearch(\"\");\n };\n\n var inputGrid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto },\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n Grid.SetColumn(button, 1);\n Grid.SetColumn(clearButton, 2);\n inputGrid.Children.Add(input);\n inputGrid.Children.Add(button);\n inputGrid.Children.Add(clearButton);\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = \"Fuzzy search scans titles, paths, message text, and code blocks. It tolerates partial words and small typos.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n inputGrid\n }\n });\n }\n\n private Border AskPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var input = new TextBox\n {\n Text = _askQuestion,\n PlaceholderText = \"Ask about a task, file, decision, error, or code snippet\",\n CornerRadius = ControlCornerRadius(),\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 86\n };\n input.TextChanged += (_, _) => _askQuestion = input.Text;\n\n var askButton = new Button\n {\n Style = (Style)Resources[\"PrimaryPillButtonStyle\"],\n Content = \"Ask\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n askButton.Click += async (_, _) =>\n {\n _askQuestion = input.Text;\n await AskArchiveAsync();\n };\n\n var settingsButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Content = \"Provider settings\",\n HorizontalAlignment = HorizontalAlignment.Left\n };\n settingsButton.Click += (_, _) => Navigate(\"Settings\");\n\n return Card(new StackPanel\n {\n Spacing = 12,\n Children =\n {\n new TextBlock { Text = provider is null ? \"No AI provider configured\" : $\"Provider: {provider.Name} / {provider.Model}\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap },\n input,\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { askButton, settingsButton }\n },\n new TextBlock { Text = _askStatus, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private async Task AskArchiveAsync()\n {\n var provider = _archive.ActiveAiProvider();\n if (provider is null)\n {\n _askStatus = \"No provider configured.\";\n RenderAsk();\n return;\n }\n if (string.IsNullOrWhiteSpace(_askQuestion))\n {\n _askStatus = \"Enter a question first.\";\n RenderAsk();\n return;\n }\n\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n _askStatus = $\"No API key saved for {provider.Name}.\";\n RenderAsk();\n return;\n }\n\n _askStatus = \"Retrieving local context and asking provider...\";\n _askAnswer = \"\";\n RenderAsk();\n\n try\n {\n var hits = _archive.RetrieveForQuestion(_askQuestion, 10);\n _askAnswer = await _ai.AskArchiveAsync(provider, key, _askQuestion, hits);\n _askStatus = $\"Answered using {hits.Count} local excerpts. Full archive was not sent.\";\n }\n catch (Exception ex)\n {\n _askStatus = $\"Ask failed: {ex.Message}\";\n }\n RenderAsk();\n }\n\n private Border ExpandableSessionGroup(string title, string subtitle, IEnumerable sessions)\n {\n var stack = new StackPanel { Spacing = 8 };\n var sessionList = sessions.Take(120).ToList();\n if (sessionList.Count == 0)\n {\n stack.Children.Add(new TextBlock { Text = \"No chats in this group.\", Foreground = MutedBrush() });\n }\n foreach (var session in sessionList)\n {\n stack.Children.Add(SessionRow(session));\n }\n\n var expander = new Expander\n {\n Background = new SolidColorBrush(Colors.Transparent),\n BorderBrush = new SolidColorBrush(Colors.Transparent),\n BorderThickness = new Thickness(0),\n Padding = new Thickness(0),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Header = new StackPanel\n {\n Spacing = 4,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = subtitle, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n },\n Content = stack\n };\n\n return Card(expander);\n }\n\n private UIElement SessionRow(ArchiveSession session)\n {\n var openButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n Content = \"Open\"\n };\n openButton.Click += (_, _) => OpenSession(session);\n\n var copyButton = new Button\n {\n Style = (Style)Resources[\"PillButtonStyle\"],\n Padding = new Thickness(12, 0, 12, 0),\n Content = \"Copy path\"\n };\n copyButton.Click += (_, _) => CopyPath(session);\n\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new StackPanel\n {\n Spacing = 3,\n Children =\n {\n new TextBlock { Text = session.DisplayTitle, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = $\"{session.DisplayDate} - {session.SourcePath}\", Foreground = MutedBrush(), FontSize = 12, TextWrapping = TextWrapping.Wrap }\n }\n });\n var actions = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { openButton, copyButton }\n };\n Grid.SetColumn(actions, 1);\n grid.Children.Add(actions);\n\n return new Border\n {\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(0, 1, 0, 0),\n Background = new SolidColorBrush(Colors.Transparent),\n Padding = new Thickness(0, 10, 0, 0),\n Child = grid\n };\n }\n\n private Border InfoPanel(string title, string body)\n {\n return Card(new StackPanel\n {\n Spacing = 8,\n Children =\n {\n new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, TextWrapping = TextWrapping.Wrap },\n new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap }\n }\n });\n }\n\n private Border SettingRow(string label, string value)\n {\n var grid = new Grid\n {\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock { Text = label, Foreground = StrongBrush(), FontSize = 16, TextWrapping = TextWrapping.Wrap });\n var valueBlock = new TextBlock\n {\n Text = value,\n Foreground = MutedBrush(),\n Margin = new Thickness(18, 0, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n Grid.SetColumn(valueBlock, 1);\n grid.Children.Add(valueBlock);\n\n return Card(grid);\n }\n\n private Border SettingsPanel(string title, string body, params UIElement[] controls)\n {\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = title, Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = body, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n foreach (var control in controls) stack.Children.Add(control);\n return Card(stack);\n }\n\n private UIElement SettingControlRow(string label, Control control)\n {\n control.HorizontalAlignment = HorizontalAlignment.Right;\n if (control is ComboBox combo) combo.CornerRadius = ControlCornerRadius();\n var grid = new Grid\n {\n MinHeight = 44,\n ColumnDefinitions =\n {\n new ColumnDefinition(),\n new ColumnDefinition { Width = GridLength.Auto }\n }\n };\n grid.Children.Add(new TextBlock\n {\n Text = label,\n Foreground = StrongBrush(),\n FontSize = 15,\n VerticalAlignment = VerticalAlignment.Center\n });\n Grid.SetColumn(control, 1);\n grid.Children.Add(control);\n return grid;\n }\n\n private ComboBox ThemeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"AMOLED Black\", Tag = \"amoled\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Graphite\", Tag = \"graphite\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Ink\", Tag = \"ink\" });\n SelectComboItem(combo, _archive.Store.Settings.Theme);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string theme) return;\n _archive.Store.Settings.Theme = theme;\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n };\n return combo;\n }\n\n private ComboBox AccentCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n foreach (var preset in AccentPresets())\n {\n combo.Items.Add(new ComboBoxItem { Content = preset.Name, Tag = preset.Hex });\n }\n SelectComboItem(combo, _archive.Store.Settings.AccentHex);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string hex) return;\n _archive.Store.Settings.Accent = item.Content?.ToString()?.ToLowerInvariant() ?? \"custom\";\n _archive.Store.Settings.AccentHex = hex;\n SetAccent(hex);\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ColorPicker AccentColorPicker()\n {\n var picker = new ColorPicker\n {\n Width = 280,\n Color = _accentColor,\n IsAlphaEnabled = false,\n IsAlphaSliderVisible = false,\n IsAlphaTextInputVisible = false\n };\n picker.ColorChanged += async (_, args) =>\n {\n if (!_storeLoaded) return;\n _archive.Store.Settings.Accent = \"custom\";\n _archive.Store.Settings.AccentHex = HexFromColor(args.NewColor);\n SetAccent(_archive.Store.Settings.AccentHex);\n await _archive.SaveAsync();\n };\n return picker;\n }\n\n private ComboBox ShapeCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Pill\", Tag = \"pill\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Rounded\", Tag = \"rounded\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n SelectComboItem(combo, _archive.Store.Settings.Radius);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string shape) return;\n _archive.Store.Settings.Radius = shape;\n SetShape(shape);\n ApplyThemeAndShape();\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private ComboBox DensityCombo()\n {\n var combo = new ComboBox { Width = 220, SelectedValuePath = \"Tag\" };\n combo.Items.Add(new ComboBoxItem { Content = \"Comfortable\", Tag = \"comfortable\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Compact\", Tag = \"compact\" });\n combo.Items.Add(new ComboBoxItem { Content = \"Spacious\", Tag = \"spacious\" });\n SelectComboItem(combo, _archive.Store.Settings.Density);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string density) return;\n _archive.Store.Settings.Density = density;\n await _archive.SaveAsync();\n RenderCurrent();\n };\n return combo;\n }\n\n private Border AiProviderPanel()\n {\n var provider = _archive.ActiveAiProvider();\n var stack = new StackPanel { Spacing = _archive.Store.Settings.Density == \"compact\" ? 10 : 14 };\n stack.Children.Add(new TextBlock { Text = \"AI providers\", Foreground = StrongBrush(), FontSize = 18, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });\n stack.Children.Add(new TextBlock { Text = \"Manage OpenAI-compatible providers. API keys are stored in Windows credentials, not app JSON.\", Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap });\n stack.Children.Add(SettingControlRow(\"Active provider\", ProviderCombo()));\n\n if (provider is not null)\n {\n stack.Children.Add(ProviderEditor(provider));\n }\n\n var addDeepSeek = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add DeepSeek preset\" };\n addDeepSeek.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addOpenAi = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add OpenAI preset\" };\n addOpenAi.Click += async (_, _) =>\n {\n _archive.EnsureAiProvider(\"OpenAI\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\");\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var addCustom = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Add custom compatible\" };\n addCustom.Click += async (_, _) => await AddCustomProviderAsync();\n\n stack.Children.Add(new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { addDeepSeek, addOpenAi, addCustom }\n });\n\n return Card(stack);\n }\n\n private ComboBox ProviderCombo()\n {\n var combo = new ComboBox { Width = 260, SelectedValuePath = \"Tag\" };\n foreach (var provider in _archive.Store.Settings.AiProviders.OrderBy(provider => provider.Name))\n {\n combo.Items.Add(new ComboBoxItem { Content = provider.Name, Tag = provider.Id });\n }\n SelectComboItem(combo, _archive.Store.Settings.ActiveAiProviderId);\n combo.SelectionChanged += async (_, _) =>\n {\n if (!_storeLoaded || combo.SelectedItem is not ComboBoxItem item || item.Tag is not string id) return;\n _archive.Store.Settings.ActiveAiProviderId = id;\n await _archive.SaveAsync();\n RenderSettings();\n };\n return combo;\n }\n\n private UIElement ProviderEditor(AiProviderSettings provider)\n {\n var nameBox = ProviderTextBox(provider.Name);\n var baseUrlBox = ProviderTextBox(provider.BaseUrl);\n var modelCombo = ProviderModelCombo(provider);\n var keyBox = new PasswordBox\n {\n Width = 360,\n PlaceholderText = HasApiKey(provider.Id) ? \"Saved. Enter a new key to replace.\" : \"Paste API key\",\n CornerRadius = ControlCornerRadius()\n };\n\n var saveButton = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Save provider\" };\n saveButton.Click += async (_, _) =>\n {\n provider.Name = string.IsNullOrWhiteSpace(nameBox.Text) ? provider.Name : nameBox.Text.Trim();\n provider.BaseUrl = baseUrlBox.Text.Trim().TrimEnd('/');\n if (!string.IsNullOrWhiteSpace(keyBox.Password)) SaveApiKey(provider.Id, keyBox.Password);\n provider.Model = SelectedModel(modelCombo) ?? provider.Model;\n await RefreshProviderModelsAsync(provider, modelCombo, showDialog: false);\n await _archive.SaveAsync();\n RenderSettings();\n };\n\n var refreshModelsButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Detect models\" };\n refreshModelsButton.Click += async (_, _) => await RefreshProviderModelsAsync(provider, modelCombo, showDialog: true);\n\n var manualModelButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Manual model\" };\n manualModelButton.Click += async (_, _) => await SetManualModelAsync(provider);\n\n var testButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Test\" };\n testButton.Click += async (_, _) => await TestProviderAsync(provider);\n\n var deleteKeyButton = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Delete key\" };\n deleteKeyButton.Click += (_, _) =>\n {\n DeleteApiKey(provider.Id);\n RenderSettings();\n };\n\n return new StackPanel\n {\n Spacing = 10,\n Children =\n {\n SettingControlRow(\"Name\", nameBox),\n SettingControlRow(\"Base URL\", baseUrlBox),\n SettingControlRow(\"Model\", modelCombo),\n SettingControlRow(\"API key\", keyBox),\n new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children = { saveButton, refreshModelsButton, manualModelButton, testButton, deleteKeyButton }\n }\n }\n };\n }\n\n private ComboBox ProviderModelCombo(AiProviderSettings provider)\n {\n var combo = new ComboBox { Width = 360, SelectedValuePath = \"Tag\" };\n var models = provider.Models\n .Append(provider.Model)\n .Where(model => !string.IsNullOrWhiteSpace(model))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(model => model, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n if (models.Count == 0)\n {\n combo.Items.Add(new ComboBoxItem { Content = \"Detect models after saving a key\", Tag = \"\" });\n }\n else\n {\n foreach (var model in models)\n {\n combo.Items.Add(new ComboBoxItem { Content = model, Tag = model });\n }\n }\n SelectComboItem(combo, provider.Model);\n return combo;\n }\n\n private static string? SelectedModel(ComboBox combo)\n {\n return combo.SelectedItem is ComboBoxItem item && item.Tag is string model && !string.IsNullOrWhiteSpace(model)\n ? model\n : null;\n }\n\n private TextBox ProviderTextBox(string value)\n {\n return new TextBox\n {\n Text = value,\n Width = 360,\n CornerRadius = ControlCornerRadius()\n };\n }\n\n private async Task AddCustomProviderAsync()\n {\n var name = new TextBox { Text = \"Custom provider\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var baseUrl = new TextBox { Text = \"https://api.example.com/v1\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var model = new TextBox { Text = \"model-name\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var content = new StackPanel\n {\n Spacing = 10,\n Children =\n {\n new TextBlock { Text = \"Name\", Foreground = StrongBrush() },\n name,\n new TextBlock { Text = \"Base URL\", Foreground = StrongBrush() },\n baseUrl,\n new TextBlock { Text = \"Model\", Foreground = StrongBrush() },\n model\n }\n };\n var dialog = new ContentDialog\n {\n Title = \"Add provider\",\n Content = content,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n _archive.EnsureAiProvider(name.Text, baseUrl.Text, model.Text);\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task RefreshProviderModelsAsync(AiProviderSettings provider, ComboBox modelCombo, bool showDialog)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", $\"Save an API key for {provider.Name} first.\");\n return;\n }\n\n try\n {\n var models = await _ai.ListModelsAsync(provider, key);\n provider.Models = models.ToList();\n if (string.IsNullOrWhiteSpace(provider.Model) || provider.Models.All(model => model != provider.Model))\n {\n provider.Model = provider.Models.FirstOrDefault() ?? provider.Model;\n }\n await _archive.SaveAsync();\n if (showDialog)\n {\n await ShowInfoAsync(\"Detect models\", $\"Found {provider.Models.Count} models for {provider.Name}.\");\n }\n RenderSettings();\n }\n catch (Exception ex)\n {\n if (showDialog) await ShowInfoAsync(\"Detect models\", ex.Message);\n modelCombo.Items.Clear();\n modelCombo.Items.Add(new ComboBoxItem { Content = provider.Model, Tag = provider.Model });\n modelCombo.SelectedIndex = 0;\n }\n }\n\n private async Task SetManualModelAsync(AiProviderSettings provider)\n {\n var input = new TextBox { Text = provider.Model, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Manual model\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n provider.Model = input.Text.Trim();\n if (!provider.Models.Contains(provider.Model, StringComparer.OrdinalIgnoreCase))\n {\n provider.Models.Add(provider.Model);\n }\n await _archive.SaveAsync();\n RenderSettings();\n }\n }\n\n private async Task TestProviderAsync(AiProviderSettings provider)\n {\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key))\n {\n await ShowInfoAsync(\"Provider test\", $\"No API key is saved for {provider.Name}.\");\n return;\n }\n\n try\n {\n var result = await _ai.TestAsync(provider, key);\n await ShowInfoAsync(\"Provider test\", $\"Success: {result}\");\n }\n catch (Exception ex)\n {\n await ShowInfoAsync(\"Provider test\", ex.Message);\n }\n }\n\n private Border EmptyBlock(string title, string body) => InfoPanel(title, body);\n\n private Border TextPanel(string text)\n {\n return Card(new TextBlock\n {\n Text = text,\n TextWrapping = TextWrapping.Wrap,\n Foreground = StrongBrush(),\n FontFamily = new FontFamily(\"Consolas\"),\n LineHeight = 21\n });\n }\n\n private Border Card(UIElement child)\n {\n return new Border\n {\n Background = PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(18),\n Child = child\n };\n }\n\n private void CopyContext_Click(object sender, RoutedEventArgs e) => Copy(\"resume\");\n private void CopyCode_Click(object sender, RoutedEventArgs e) => Copy(\"code\");\n private void CopyPath_Click(object sender, RoutedEventArgs e) => Copy(\"path\");\n\n private void Copy(string mode)\n {\n if (_selected is null) return;\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(_selected, mode));\n Clipboard.SetContent(package);\n }\n\n private void CopyPath(ArchiveSession session)\n {\n var package = new DataPackage();\n package.SetText(_archive.CopyPayload(session, \"path\"));\n Clipboard.SetContent(package);\n }\n\n private void OpenSession(ArchiveSession session)\n {\n _selected = session;\n SessionList.SelectedItem = session;\n Navigate(\"Archive\");\n }\n\n private void Source_Click(object sender, RoutedEventArgs e) => Navigate(\"Source\");\n private void Restore_Click(object sender, RoutedEventArgs e) => Navigate(\"Restore\");\n\n private void Review_Click(object sender, RoutedEventArgs e)\n {\n if (_selected is not null && !_selected.Tags.Contains(\"review-later\"))\n {\n _selected.Tags.Add(\"review-later\");\n RenderTags();\n }\n }\n\n private void SessionList_RightTapped(object sender, RightTappedRoutedEventArgs e)\n {\n var session = FindSessionFromElement(e.OriginalSource as DependencyObject) ?? SessionList.SelectedItem as ArchiveSession;\n if (session is null) return;\n\n _selected = session;\n SessionList.SelectedItem = session;\n\n var flyout = new MenuFlyout();\n var pinItem = new MenuFlyoutItem { Text = session.Pinned ? \"Unpin chat\" : \"Pin chat\" };\n pinItem.Click += async (_, _) => await TogglePinSelected();\n flyout.Items.Add(pinItem);\n\n var renameItem = new MenuFlyoutItem { Text = \"Rename chat\" };\n renameItem.Click += async (_, _) => await RenameSelected();\n flyout.Items.Add(renameItem);\n\n var addToCollection = new MenuFlyoutSubItem { Text = \"Add to collection\" };\n foreach (var collection in _archive.Store.Collections.Values.OrderBy(c => c.Name))\n {\n var collectionItem = new MenuFlyoutItem { Text = collection.Name, Tag = collection.Name };\n collectionItem.Click += async (menuSender, _) =>\n {\n if ((menuSender as MenuFlyoutItem)?.Tag is string name && _selected is not null)\n {\n await _archive.AddToCollectionAsync(_selected, name);\n RenderCurrent();\n }\n };\n addToCollection.Items.Add(collectionItem);\n }\n var newCollectionItem = new MenuFlyoutItem { Text = \"New collection...\" };\n newCollectionItem.Click += async (_, _) => await AddSelectedToNewCollection();\n addToCollection.Items.Add(newCollectionItem);\n flyout.Items.Add(addToCollection);\n\n var copyItem = new MenuFlyoutItem { Text = \"Copy restore packet\" };\n copyItem.Click += (_, _) => Copy(\"restore\");\n flyout.Items.Add(copyItem);\n\n var copyPathItem = new MenuFlyoutItem { Text = \"Copy chat path\" };\n copyPathItem.Click += (_, _) => Copy(\"path\");\n flyout.Items.Add(copyPathItem);\n\n var archiveItem = new MenuFlyoutItem { Text = \"Archive chat\" };\n archiveItem.Click += async (_, _) => await ArchiveSelected();\n flyout.Items.Add(archiveItem);\n\n flyout.ShowAt(SessionList, e.GetPosition(SessionList));\n }\n\n private void SetAccent(string hex)\n {\n _accentColor = Windows.UI.Color.FromArgb(\n 255,\n Convert.ToByte(hex.Substring(1, 2), 16),\n Convert.ToByte(hex.Substring(3, 2), 16),\n Convert.ToByte(hex.Substring(5, 2), 16));\n SetBrush(\"AccentBrush\", _accentColor);\n RenderTags();\n }\n\n private void ApplyThemeAndShape()\n {\n SetShape(_archive.Store.Settings.Radius);\n switch (_archive.Store.Settings.Theme)\n {\n case \"graphite\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 6, 7, 8));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 14, 15, 18));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 24, 25, 29));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 44, 46, 54));\n break;\n case \"ink\":\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 4, 4, 6));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 11, 11, 14));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 33, 34, 40));\n break;\n default:\n SetBrush(\"AmoledBrush\", Windows.UI.Color.FromArgb(255, 0, 0, 0));\n SetBrush(\"PanelBrush\", Windows.UI.Color.FromArgb(255, 7, 8, 10));\n SetBrush(\"RaisedBrush\", Windows.UI.Color.FromArgb(255, 16, 17, 20));\n SetBrush(\"LineBrush\", Windows.UI.Color.FromArgb(255, 36, 38, 45));\n break;\n }\n\n SetAccent(_archive.Store.Settings.AccentHex);\n ApplyShapeToTree(this);\n }\n\n private void SetShape(string shape)\n {\n (_panelRadius, _controlRadius) = shape switch\n {\n \"compact\" => (12, 12),\n \"rounded\" => (18, 16),\n _ => (24, 20)\n };\n _archive.Store.Settings.PanelRadius = _panelRadius;\n _archive.Store.Settings.ControlRadius = _controlRadius;\n }\n\n private void ApplyShapeToTree(DependencyObject root)\n {\n if (root is Button button) button.CornerRadius = ControlCornerRadius();\n if (root is TextBox textBox) textBox.CornerRadius = ControlCornerRadius();\n if (root is ComboBox comboBox) comboBox.CornerRadius = ControlCornerRadius();\n if (root is Border border && border.CornerRadius.TopLeft > 0)\n {\n border.CornerRadius = border.ActualWidth <= 48 && border.ActualHeight <= 48\n ? new CornerRadius(Math.Min(border.ActualWidth, border.ActualHeight) / 2)\n : PanelCornerRadius();\n }\n var count = VisualTreeHelper.GetChildrenCount(root);\n for (var i = 0; i < count; i++) ApplyShapeToTree(VisualTreeHelper.GetChild(root, i));\n }\n\n private static void SetBrush(string key, Windows.UI.Color color)\n {\n if (Application.Current.Resources[key] is SolidColorBrush brush) brush.Color = color;\n else Application.Current.Resources[key] = new SolidColorBrush(color);\n }\n\n private static void SelectComboItem(ComboBox combo, string value)\n {\n foreach (var item in combo.Items.OfType())\n {\n if (string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))\n {\n combo.SelectedItem = item;\n return;\n }\n }\n combo.SelectedIndex = 0;\n }\n\n private static IReadOnlyList AccentPresets() =>\n [\n new(\"Rose\", \"#fb7185\"),\n new(\"Mint\", \"#43d6a4\"),\n new(\"Violet\", \"#a78bfa\"),\n new(\"Amber\", \"#f6b44b\"),\n new(\"Blue\", \"#7aa2ff\")\n ];\n\n private async Task TogglePinSelected()\n {\n if (_selected is null) return;\n await _archive.TogglePinAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task RenameSelected()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = _selected.DisplayTitle, MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"Rename chat\",\n Content = input,\n PrimaryButtonText = \"Save\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary)\n {\n await _archive.RenameSessionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task AddSelectedToNewCollection()\n {\n if (_selected is null) return;\n var input = new TextBox { Text = \"Saved\", MinWidth = 420, CornerRadius = ControlCornerRadius() };\n var dialog = new ContentDialog\n {\n Title = \"New collection\",\n Content = input,\n PrimaryButtonText = \"Add\",\n CloseButtonText = \"Cancel\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n if (await dialog.ShowAsync() == ContentDialogResult.Primary && !string.IsNullOrWhiteSpace(input.Text))\n {\n await _archive.AddToCollectionAsync(_selected, input.Text);\n RenderCurrent();\n }\n }\n\n private async Task ArchiveSelected()\n {\n if (_selected is null) return;\n await _archive.ArchiveSessionAsync(_selected);\n SelectFirstSession();\n RenderCurrent();\n }\n\n private async Task ShowInfoAsync(string title, string message)\n {\n var dialog = new ContentDialog\n {\n Title = title,\n Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },\n CloseButtonText = \"OK\",\n XamlRoot = XamlRoot\n };\n await dialog.ShowAsync();\n }\n\n private static string CredentialResource(string providerId) => $\"CodexLocalRetrieval.ApiKey.{providerId}\";\n\n private static bool HasApiKey(string providerId) => !string.IsNullOrWhiteSpace(LoadApiKey(providerId));\n\n private static string LoadApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n credential.RetrievePassword();\n return credential.Password;\n }\n catch\n {\n return \"\";\n }\n }\n\n private static void SaveApiKey(string providerId, string apiKey)\n {\n DeleteApiKey(providerId);\n var vault = new PasswordVault();\n vault.Add(new PasswordCredential(CredentialResource(providerId), Environment.UserName, apiKey));\n }\n\n private static void DeleteApiKey(string providerId)\n {\n try\n {\n var vault = new PasswordVault();\n var credential = vault.Retrieve(CredentialResource(providerId), Environment.UserName);\n vault.Remove(credential);\n }\n catch\n {\n // Missing credentials are fine.\n }\n }\n\n private static ArchiveSession? FindSessionFromElement(DependencyObject? source)\n {\n while (source is not null)\n {\n if (source is FrameworkElement { DataContext: ArchiveSession session }) return session;\n source = VisualTreeHelper.GetParent(source);\n }\n return null;\n }\n\n private static string StripCode(string text) => Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n private static string FormatDate(string value) => DateTime.TryParse(value, out var date) ? date.ToString(\"MMM d\") : \"\";\n private static string HexFromColor(Windows.UI.Color color) => $\"#{color.R:X2}{color.G:X2}{color.B:X2}\".ToLowerInvariant();\n private SolidColorBrush StrongBrush() => (SolidColorBrush)Application.Current.Resources[\"TextStrongBrush\"];\n private SolidColorBrush MutedBrush() => (SolidColorBrush)Application.Current.Resources[\"TextMutedBrush\"];\n private SolidColorBrush PanelBrush() => (SolidColorBrush)Application.Current.Resources[\"PanelBrush\"];\n private SolidColorBrush LineBrush() => (SolidColorBrush)Application.Current.Resources[\"LineBrush\"];\n private SolidColorBrush AccentSoftBrush() => new(Windows.UI.Color.FromArgb(90, _accentColor.R, _accentColor.G, _accentColor.B));\n private SolidColorBrush AccentVerySoftBrush() => new(Windows.UI.Color.FromArgb(30, _accentColor.R, _accentColor.G, _accentColor.B));\n private CornerRadius PanelCornerRadius() => new(_panelRadius);\n private CornerRadius ControlCornerRadius() => new(_controlRadius);\n private sealed record AccentPreset(string Name, string Hex);\n}\n"}}, "native/CodexLocalRetrieval.Native/MainPage.Copilot.cs": {"0bd60df9ea622f66f6e6720e6824fe5916840a06": {"t": 1785745824.501204, "n": 18557, "h": "8a5045d4d4e54f3d", "c": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Threading.Tasks;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI.Text;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\n\nnamespace CodexLocalRetrieval_Native;\n\n// The co-pilot: a multi-turn chat over the archive backed by DeepSeek (key autograbbed from the\n// environment) and the narrow ArchiveToolService. Mutations route through a confirm dialog; show_chat\n// opens a floating preview window. Tool results are data — the system prompt forbids obeying them.\npublic sealed partial class MainPage\n{\n private readonly List _copilotApi = new();\n private readonly List _previewWindows = new();\n private bool _copilotBusy;\n private string _copilotStatus = \"\";\n private string _copilotBackend = \"deepseek\"; // deepseek | claude-cli\n private System.Threading.CancellationTokenSource? _copilotCts;\n\n private static readonly string[] CopilotSuggestions =\n {\n \"Summarize my most recent chat\",\n \"Find chats about VENPOD and list them\",\n \"Open my most recent chat in a preview window\",\n \"Resume my most recent chat in a terminal\",\n \"Make a restore packet for my latest chat\"\n };\n\n private void ResetCopilot()\n {\n _copilotApi.Clear();\n _copilotApi.Add(ChatMessage.System(ArchiveToolService.SystemPrompt));\n _copilotStatus = \"\";\n }\n\n private IChatBackend? BuildCopilotBackend()\n {\n if (_copilotBackend == \"claude-cli\")\n return new ClaudexBackend(ArchiveService.ResolveClaudeExe());\n\n var provider = _archive.ActiveAiProvider()\n ?? _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key)) return null;\n return new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n }\n\n private void RenderCopilot()\n {\n ScreenLabel.Text = \"Co-pilot\";\n TitleText.Text = \"Chat about your archive\";\n MainContent.Children.Clear();\n if (_copilotApi.Count == 0) ResetCopilot();\n\n MainContent.Children.Add(CopilotComposer());\n\n if (_copilotBusy) MainContent.Children.Add(CopilotNote(\"Thinking...\"));\n if (!string.IsNullOrWhiteSpace(_copilotStatus)) MainContent.Children.Add(CopilotNote(_copilotStatus));\n\n // Newest exchange first, directly under the composer, so the latest reply is visible\n // without scrolling. System + tool messages are not shown; tool calls render as chips.\n var visible = _copilotApi.Where(m => m.Role is \"user\" or \"assistant\").ToList();\n for (var i = visible.Count - 1; i >= 0; i--)\n {\n var m = visible[i];\n if (m.Role == \"user\")\n {\n MainContent.Children.Add(CopilotBubble(\"You\", m.Content ?? \"\", true));\n }\n else\n {\n if (m.ToolCalls is { Count: > 0 })\n foreach (var call in m.ToolCalls) MainContent.Children.Add(ToolChip(call.Function.Name, call.Function.Arguments));\n if (!string.IsNullOrWhiteSpace(m.Content))\n MainContent.Children.Add(CopilotBubble(\"Co-pilot\", m.Content!, false));\n }\n }\n\n if (visible.Count == 0) MainContent.Children.Add(CopilotSuggestionsCard());\n }\n\n private UIElement CopilotComposer()\n {\n var input = new TextBox\n {\n PlaceholderText = \"Ask about your chats, or tell me to organize them...\",\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 64,\n MaxHeight = 160,\n CornerRadius = ControlCornerRadius(),\n IsEnabled = !_copilotBusy\n };\n input.KeyDown += async (_, e) =>\n {\n if (e.Key == Windows.System.VirtualKey.Enter &&\n (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift) & Windows.UI.Core.CoreVirtualKeyStates.Down) == 0)\n {\n e.Handled = true;\n var text = input.Text;\n input.Text = \"\";\n await SendCopilotAsync(text);\n }\n };\n\n var send = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Send\", IsEnabled = !_copilotBusy };\n send.Click += async (_, _) => { var t = input.Text; input.Text = \"\"; await SendCopilotAsync(t); };\n\n // While a turn is running, Send is replaced by Stop so the user can abort a long round.\n var stop = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Stop\" };\n stop.Click += (_, _) => CancelCopilot();\n\n var reset = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"New chat\", IsEnabled = !_copilotBusy };\n reset.Click += (_, _) => { if (!_copilotBusy) { ResetCopilot(); RenderCopilot(); } };\n\n var backendCombo = new ComboBox { MinWidth = 140, CornerRadius = ControlCornerRadius(), VerticalAlignment = VerticalAlignment.Center, IsEnabled = !_copilotBusy };\n backendCombo.Items.Add(new ComboBoxItem { Content = \"DeepSeek\", Tag = \"deepseek\" });\n backendCombo.Items.Add(new ComboBoxItem { Content = \"Claude (CLI)\", Tag = \"claude-cli\" });\n backendCombo.SelectedIndex = _copilotBackend == \"claude-cli\" ? 1 : 0;\n backendCombo.SelectionChanged += (_, _) =>\n {\n if (backendCombo.SelectedItem is ComboBoxItem item && item.Tag is string tag && tag != _copilotBackend)\n {\n _copilotBackend = tag;\n RenderCopilot();\n }\n };\n\n // The backend note lives on its OWN line below the controls so it never gets crushed between\n // the dropdown and the action buttons (it was truncating at narrow widths).\n var note = new TextBlock\n {\n Text = _copilotBackend == \"claude-cli\"\n ? \"Claude CLI — experimental, plain chat (no archive tools)\"\n : \"DeepSeek key: \" + ApiKeySource(\"deepseek\"),\n Foreground = MutedBrush(),\n FontSize = 11,\n Margin = new Thickness(2, 8, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n\n var buttons = new Grid { Margin = new Thickness(0, 10, 0, 0), ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n backendCombo.VerticalAlignment = VerticalAlignment.Center;\n buttons.Children.Add(backendCombo);\n var right = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };\n right.Children.Add(reset);\n right.Children.Add(_copilotBusy ? stop : send);\n Grid.SetColumn(right, 1);\n buttons.Children.Add(right);\n\n return Card(new StackPanel { Spacing = 0, Children = { input, buttons, note } });\n }\n\n private UIElement CopilotSuggestionsCard()\n {\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock { Text = \"Try asking\", Foreground = StrongBrush(), FontSize = 15, FontWeight = FontWeights.SemiBold });\n foreach (var s in CopilotSuggestions)\n {\n var b = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = s, HorizontalAlignment = HorizontalAlignment.Left };\n b.Click += async (_, _) => await SendCopilotAsync(s);\n stack.Children.Add(b);\n }\n return Card(stack);\n }\n\n private async Task SendCopilotAsync(string text)\n {\n if (_copilotBusy || string.IsNullOrWhiteSpace(text)) return;\n if (_copilotApi.Count == 0) ResetCopilot();\n\n var backend = BuildCopilotBackend();\n if (backend is null)\n {\n _copilotStatus = \"No DeepSeek key found. Set DEEPSEEK_API_KEY in your environment (or add a provider in Settings), then try again.\";\n RenderCopilot();\n return;\n }\n\n _copilotApi.Add(ChatMessage.User(text.Trim()));\n TrimCopilotContext();\n _copilotBusy = true;\n _copilotStatus = \"\";\n _copilotCts = new System.Threading.CancellationTokenSource();\n RenderCopilot();\n\n try\n {\n var tools = new ArchiveToolService(_archive, showChat: OpenChatPreview, resumeChat: ResumeChatFromCopilot).Tools();\n var orchestrator = new ChatOrchestrator(backend, tools, confirm: ConfirmCopilotActionAsync);\n var result = await orchestrator.RunAsync(_copilotApi, _copilotCts.Token);\n if (string.IsNullOrWhiteSpace(result.Answer) && !string.IsNullOrWhiteSpace(result.Error))\n _copilotStatus = result.Error!;\n }\n catch (OperationCanceledException)\n {\n _copilotStatus = \"Stopped.\"; // user hit Stop — not an error\n }\n catch (Exception ex)\n {\n Diag.Log(\"Copilot error \" + ex);\n _copilotStatus = \"Error talking to the model: \" + ex.Message;\n }\n finally\n {\n _copilotBusy = false;\n _copilotCts?.Dispose();\n _copilotCts = null;\n RenderCopilot();\n }\n }\n\n // Cancels the in-flight turn. The orchestrator threads this token into the backend call, so a\n // long DeepSeek round (or a retry/backoff wait) aborts promptly. Guarded because the CTS is\n // disposed+nulled in SendCopilotAsync's finally (a late click could otherwise race it).\n private void CancelCopilot()\n {\n try { _copilotCts?.Cancel(); } catch (ObjectDisposedException) { }\n }\n\n // Keep the system message + the most recent whole turns, so a long session can't grow the context\n // (or the cost) without bound. Trimming by whole user-turns avoids leaving a tool message dangling\n // without its assistant tool_call (which the API rejects).\n private void TrimCopilotContext()\n {\n const int maxTurns = 12;\n var userIndices = new List();\n for (var i = 1; i < _copilotApi.Count; i++)\n if (_copilotApi[i].Role == \"user\") userIndices.Add(i);\n if (userIndices.Count <= maxTurns) return;\n var keepFrom = userIndices[userIndices.Count - maxTurns];\n _copilotApi.RemoveRange(1, keepFrom - 1); // keep index 0 (system) + everything from keepFrom\n }\n\n private async Task ConfirmCopilotActionAsync(ChatTool tool, JsonElement args)\n {\n Diag.Log($\"Copilot confirm requested for tool: {tool.Name} {args}\");\n\n string body;\n var defaultButton = ContentDialogButton.Primary;\n if (tool.Name == \"resume_chat\")\n {\n var id = args.TryGetProperty(\"id\", out var idEl) && idEl.ValueKind == JsonValueKind.String ? idEl.GetString() ?? \"\" : \"\";\n var session = _archive.GetSession(id);\n if (session is null)\n {\n body = \"Resume a chat in a new terminal?\";\n }\n else\n {\n var launch = _archive.BuildResumeLaunch(session);\n body = $\"Open a NEW terminal and resume “{session.DisplayTitle}”?\\n\\n\" +\n \"This launches an external agent CLI:\\n\" +\n $\" {launch.DisplayCommand}\\n\" +\n $\"in {launch.WorkingDirectory}\";\n }\n defaultButton = ContentDialogButton.Close; // a terminal launch should default to Skip\n }\n else\n {\n body = $\"Let the co-pilot run “{tool.Name}”?\\n\\n{args}\\n\\nThis changes app metadata only — your chat files are never modified.\";\n }\n\n var dialog = new ContentDialog\n {\n Title = \"Allow this action?\",\n Content = body,\n PrimaryButtonText = \"Allow\",\n CloseButtonText = \"Skip\",\n DefaultButton = defaultButton,\n XamlRoot = XamlRoot\n };\n return await dialog.ShowAsync() == ContentDialogResult.Primary;\n }\n\n private void ResumeChatFromCopilot(string id)\n {\n var session = _archive.GetSession(id);\n if (session is not null) DispatcherQueue.TryEnqueue(() => ResumeInTerminal(session));\n }\n\n private async void OpenChatPreview(string id)\n {\n var session = _archive.GetSession(id);\n if (session is null) return;\n await _archive.EnsureContentAsync(session); // content lazy-loads from the source file\n var snapshot = session.Messages\n .Take(80)\n .Select(m => (m.RoleLabel, ArchiveService.ForReading(m.Text)))\n .ToList();\n var title = session.DisplayTitle;\n var subtitle = $\"{(string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} · {session.WorkspaceName}\";\n DispatcherQueue.TryEnqueue(() =>\n {\n try\n {\n var window = new ChatPreviewWindow(title, subtitle, snapshot);\n _previewWindows.Add(window);\n window.Closed += (_, _) => _previewWindows.Remove(window);\n window.Activate();\n Diag.Log($\"Preview window opened for \\\"{title}\\\" ({_previewWindows.Count} open)\");\n }\n catch (Exception ex) { Diag.Log(\"Preview window failed \" + ex); }\n });\n }\n\n private UIElement CopilotBubble(string who, string text, bool isUser)\n {\n var stack = new StackPanel { Spacing = 6 };\n stack.Children.Add(new TextBlock { Text = who, Foreground = MutedBrush(), FontSize = 12 });\n if (isUser)\n stack.Children.Add(new TextBlock { Text = text, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 22, FontSize = 14 });\n else\n stack.Children.Add(BuildMarkdown(text)); // assistant replies are markdown\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(16),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Child = stack\n };\n }\n\n private UIElement ToolChip(string name, string argsJson)\n {\n var text = string.IsNullOrWhiteSpace(argsJson) || argsJson == \"{}\" ? name : $\"{name} {Trim(argsJson, 80)}\";\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(36, 120, 170, 255)),\n CornerRadius = new CornerRadius(8),\n Padding = new Thickness(10, 4, 10, 4),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children =\n {\n new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 12, Glyph = \"\", Foreground = MutedBrush() },\n new TextBlock { Text = text, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center }\n }\n }\n };\n }\n\n private UIElement CopilotNote(string text) =>\n new TextBlock { Text = text, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(2, 0, 0, 0) };\n\n // Lightweight markdown -> RichTextBlock: headers, **bold**, `code`, bullets, and table rows\n // (kept monospaced). Robust to unclosed markers; not a full parser, just readable formatting.\n private RichTextBlock BuildMarkdown(string text)\n {\n var rtb = new RichTextBlock { TextWrapping = TextWrapping.Wrap, Foreground = StrongBrush(), LineHeight = 22, FontSize = 14 };\n foreach (var raw in (text ?? \"\").Replace(\"\\r\", \"\").Split('\\n'))\n {\n var line = raw;\n var para = new Microsoft.UI.Xaml.Documents.Paragraph();\n\n var t = line.Trim();\n if (t is \"---\" or \"***\" or \"___\")\n {\n para.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = \"──────────\", Foreground = MutedBrush() });\n rtb.Blocks.Add(para);\n continue;\n }\n\n var hashes = 0;\n while (hashes < line.Length && line[hashes] == '#') hashes++;\n if (hashes > 0 && hashes < line.Length && line[hashes] == ' ')\n {\n line = line[(hashes + 1)..];\n para.FontWeight = FontWeights.SemiBold;\n para.FontSize = hashes <= 2 ? 16 : 15;\n para.Margin = new Thickness(0, 8, 0, 2);\n }\n\n var trimmedStart = line.TrimStart();\n if (trimmedStart.StartsWith(\"- \") || trimmedStart.StartsWith(\"* \"))\n line = \" • \" + trimmedStart[2..];\n\n var monospace = line.Contains('|'); // table row\n AddInlineRuns(para.Inlines, line, monospace);\n rtb.Blocks.Add(para);\n }\n return rtb;\n }\n\n private void AddInlineRuns(Microsoft.UI.Xaml.Documents.InlineCollection inlines, string line, bool monospace)\n {\n var i = 0;\n while (i < line.Length)\n {\n if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '*')\n {\n var end = line.IndexOf(\"**\", i + 2, StringComparison.Ordinal);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 2)..end], FontWeight = FontWeights.Bold }); i = end + 2; continue; }\n }\n if (line[i] == '`')\n {\n var end = line.IndexOf('`', i + 1);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 1)..end], FontFamily = new FontFamily(\"Consolas\") }); i = end + 1; continue; }\n }\n var next = NextMarker(line, i);\n var run = new Microsoft.UI.Xaml.Documents.Run { Text = line[i..next] };\n if (monospace) run.FontFamily = new FontFamily(\"Consolas\");\n inlines.Add(run);\n i = next;\n }\n }\n\n private static int NextMarker(string s, int from)\n {\n for (var k = from + 1; k < s.Length; k++)\n {\n if (s[k] == '`') return k;\n if (s[k] == '*' && k + 1 < s.Length && s[k + 1] == '*') return k;\n }\n return s.Length;\n }\n}\n"}, "da60fe86cdb47d72d8dbc55a9c362b519aadaef5": {"t": 1785756762.8821776, "n": 18557, "h": "8a5045d4d4e54f3d", "c": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Threading.Tasks;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI.Text;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\n\nnamespace CodexLocalRetrieval_Native;\n\n// The co-pilot: a multi-turn chat over the archive backed by DeepSeek (key autograbbed from the\n// environment) and the narrow ArchiveToolService. Mutations route through a confirm dialog; show_chat\n// opens a floating preview window. Tool results are data — the system prompt forbids obeying them.\npublic sealed partial class MainPage\n{\n private readonly List _copilotApi = new();\n private readonly List _previewWindows = new();\n private bool _copilotBusy;\n private string _copilotStatus = \"\";\n private string _copilotBackend = \"deepseek\"; // deepseek | claude-cli\n private System.Threading.CancellationTokenSource? _copilotCts;\n\n private static readonly string[] CopilotSuggestions =\n {\n \"Summarize my most recent chat\",\n \"Find chats about VENPOD and list them\",\n \"Open my most recent chat in a preview window\",\n \"Resume my most recent chat in a terminal\",\n \"Make a restore packet for my latest chat\"\n };\n\n private void ResetCopilot()\n {\n _copilotApi.Clear();\n _copilotApi.Add(ChatMessage.System(ArchiveToolService.SystemPrompt));\n _copilotStatus = \"\";\n }\n\n private IChatBackend? BuildCopilotBackend()\n {\n if (_copilotBackend == \"claude-cli\")\n return new ClaudexBackend(ArchiveService.ResolveClaudeExe());\n\n var provider = _archive.ActiveAiProvider()\n ?? _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key)) return null;\n return new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n }\n\n private void RenderCopilot()\n {\n ScreenLabel.Text = \"Co-pilot\";\n TitleText.Text = \"Chat about your archive\";\n MainContent.Children.Clear();\n if (_copilotApi.Count == 0) ResetCopilot();\n\n MainContent.Children.Add(CopilotComposer());\n\n if (_copilotBusy) MainContent.Children.Add(CopilotNote(\"Thinking...\"));\n if (!string.IsNullOrWhiteSpace(_copilotStatus)) MainContent.Children.Add(CopilotNote(_copilotStatus));\n\n // Newest exchange first, directly under the composer, so the latest reply is visible\n // without scrolling. System + tool messages are not shown; tool calls render as chips.\n var visible = _copilotApi.Where(m => m.Role is \"user\" or \"assistant\").ToList();\n for (var i = visible.Count - 1; i >= 0; i--)\n {\n var m = visible[i];\n if (m.Role == \"user\")\n {\n MainContent.Children.Add(CopilotBubble(\"You\", m.Content ?? \"\", true));\n }\n else\n {\n if (m.ToolCalls is { Count: > 0 })\n foreach (var call in m.ToolCalls) MainContent.Children.Add(ToolChip(call.Function.Name, call.Function.Arguments));\n if (!string.IsNullOrWhiteSpace(m.Content))\n MainContent.Children.Add(CopilotBubble(\"Co-pilot\", m.Content!, false));\n }\n }\n\n if (visible.Count == 0) MainContent.Children.Add(CopilotSuggestionsCard());\n }\n\n private UIElement CopilotComposer()\n {\n var input = new TextBox\n {\n PlaceholderText = \"Ask about your chats, or tell me to organize them...\",\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 64,\n MaxHeight = 160,\n CornerRadius = ControlCornerRadius(),\n IsEnabled = !_copilotBusy\n };\n input.KeyDown += async (_, e) =>\n {\n if (e.Key == Windows.System.VirtualKey.Enter &&\n (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift) & Windows.UI.Core.CoreVirtualKeyStates.Down) == 0)\n {\n e.Handled = true;\n var text = input.Text;\n input.Text = \"\";\n await SendCopilotAsync(text);\n }\n };\n\n var send = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Send\", IsEnabled = !_copilotBusy };\n send.Click += async (_, _) => { var t = input.Text; input.Text = \"\"; await SendCopilotAsync(t); };\n\n // While a turn is running, Send is replaced by Stop so the user can abort a long round.\n var stop = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Stop\" };\n stop.Click += (_, _) => CancelCopilot();\n\n var reset = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"New chat\", IsEnabled = !_copilotBusy };\n reset.Click += (_, _) => { if (!_copilotBusy) { ResetCopilot(); RenderCopilot(); } };\n\n var backendCombo = new ComboBox { MinWidth = 140, CornerRadius = ControlCornerRadius(), VerticalAlignment = VerticalAlignment.Center, IsEnabled = !_copilotBusy };\n backendCombo.Items.Add(new ComboBoxItem { Content = \"DeepSeek\", Tag = \"deepseek\" });\n backendCombo.Items.Add(new ComboBoxItem { Content = \"Claude (CLI)\", Tag = \"claude-cli\" });\n backendCombo.SelectedIndex = _copilotBackend == \"claude-cli\" ? 1 : 0;\n backendCombo.SelectionChanged += (_, _) =>\n {\n if (backendCombo.SelectedItem is ComboBoxItem item && item.Tag is string tag && tag != _copilotBackend)\n {\n _copilotBackend = tag;\n RenderCopilot();\n }\n };\n\n // The backend note lives on its OWN line below the controls so it never gets crushed between\n // the dropdown and the action buttons (it was truncating at narrow widths).\n var note = new TextBlock\n {\n Text = _copilotBackend == \"claude-cli\"\n ? \"Claude CLI — experimental, plain chat (no archive tools)\"\n : \"DeepSeek key: \" + ApiKeySource(\"deepseek\"),\n Foreground = MutedBrush(),\n FontSize = 11,\n Margin = new Thickness(2, 8, 0, 0),\n TextWrapping = TextWrapping.Wrap\n };\n\n var buttons = new Grid { Margin = new Thickness(0, 10, 0, 0), ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n backendCombo.VerticalAlignment = VerticalAlignment.Center;\n buttons.Children.Add(backendCombo);\n var right = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };\n right.Children.Add(reset);\n right.Children.Add(_copilotBusy ? stop : send);\n Grid.SetColumn(right, 1);\n buttons.Children.Add(right);\n\n return Card(new StackPanel { Spacing = 0, Children = { input, buttons, note } });\n }\n\n private UIElement CopilotSuggestionsCard()\n {\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock { Text = \"Try asking\", Foreground = StrongBrush(), FontSize = 15, FontWeight = FontWeights.SemiBold });\n foreach (var s in CopilotSuggestions)\n {\n var b = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = s, HorizontalAlignment = HorizontalAlignment.Left };\n b.Click += async (_, _) => await SendCopilotAsync(s);\n stack.Children.Add(b);\n }\n return Card(stack);\n }\n\n private async Task SendCopilotAsync(string text)\n {\n if (_copilotBusy || string.IsNullOrWhiteSpace(text)) return;\n if (_copilotApi.Count == 0) ResetCopilot();\n\n var backend = BuildCopilotBackend();\n if (backend is null)\n {\n _copilotStatus = \"No DeepSeek key found. Set DEEPSEEK_API_KEY in your environment (or add a provider in Settings), then try again.\";\n RenderCopilot();\n return;\n }\n\n _copilotApi.Add(ChatMessage.User(text.Trim()));\n TrimCopilotContext();\n _copilotBusy = true;\n _copilotStatus = \"\";\n _copilotCts = new System.Threading.CancellationTokenSource();\n RenderCopilot();\n\n try\n {\n var tools = new ArchiveToolService(_archive, showChat: OpenChatPreview, resumeChat: ResumeChatFromCopilot).Tools();\n var orchestrator = new ChatOrchestrator(backend, tools, confirm: ConfirmCopilotActionAsync);\n var result = await orchestrator.RunAsync(_copilotApi, _copilotCts.Token);\n if (string.IsNullOrWhiteSpace(result.Answer) && !string.IsNullOrWhiteSpace(result.Error))\n _copilotStatus = result.Error!;\n }\n catch (OperationCanceledException)\n {\n _copilotStatus = \"Stopped.\"; // user hit Stop — not an error\n }\n catch (Exception ex)\n {\n Diag.Log(\"Copilot error \" + ex);\n _copilotStatus = \"Error talking to the model: \" + ex.Message;\n }\n finally\n {\n _copilotBusy = false;\n _copilotCts?.Dispose();\n _copilotCts = null;\n RenderCopilot();\n }\n }\n\n // Cancels the in-flight turn. The orchestrator threads this token into the backend call, so a\n // long DeepSeek round (or a retry/backoff wait) aborts promptly. Guarded because the CTS is\n // disposed+nulled in SendCopilotAsync's finally (a late click could otherwise race it).\n private void CancelCopilot()\n {\n try { _copilotCts?.Cancel(); } catch (ObjectDisposedException) { }\n }\n\n // Keep the system message + the most recent whole turns, so a long session can't grow the context\n // (or the cost) without bound. Trimming by whole user-turns avoids leaving a tool message dangling\n // without its assistant tool_call (which the API rejects).\n private void TrimCopilotContext()\n {\n const int maxTurns = 12;\n var userIndices = new List();\n for (var i = 1; i < _copilotApi.Count; i++)\n if (_copilotApi[i].Role == \"user\") userIndices.Add(i);\n if (userIndices.Count <= maxTurns) return;\n var keepFrom = userIndices[userIndices.Count - maxTurns];\n _copilotApi.RemoveRange(1, keepFrom - 1); // keep index 0 (system) + everything from keepFrom\n }\n\n private async Task ConfirmCopilotActionAsync(ChatTool tool, JsonElement args)\n {\n Diag.Log($\"Copilot confirm requested for tool: {tool.Name} {args}\");\n\n string body;\n var defaultButton = ContentDialogButton.Primary;\n if (tool.Name == \"resume_chat\")\n {\n var id = args.TryGetProperty(\"id\", out var idEl) && idEl.ValueKind == JsonValueKind.String ? idEl.GetString() ?? \"\" : \"\";\n var session = _archive.GetSession(id);\n if (session is null)\n {\n body = \"Resume a chat in a new terminal?\";\n }\n else\n {\n var launch = _archive.BuildResumeLaunch(session);\n body = $\"Open a NEW terminal and resume “{session.DisplayTitle}”?\\n\\n\" +\n \"This launches an external agent CLI:\\n\" +\n $\" {launch.DisplayCommand}\\n\" +\n $\"in {launch.WorkingDirectory}\";\n }\n defaultButton = ContentDialogButton.Close; // a terminal launch should default to Skip\n }\n else\n {\n body = $\"Let the co-pilot run “{tool.Name}”?\\n\\n{args}\\n\\nThis changes app metadata only — your chat files are never modified.\";\n }\n\n var dialog = new ContentDialog\n {\n Title = \"Allow this action?\",\n Content = body,\n PrimaryButtonText = \"Allow\",\n CloseButtonText = \"Skip\",\n DefaultButton = defaultButton,\n XamlRoot = XamlRoot\n };\n return await dialog.ShowAsync() == ContentDialogResult.Primary;\n }\n\n private void ResumeChatFromCopilot(string id)\n {\n var session = _archive.GetSession(id);\n if (session is not null) DispatcherQueue.TryEnqueue(() => ResumeInTerminal(session));\n }\n\n private async void OpenChatPreview(string id)\n {\n var session = _archive.GetSession(id);\n if (session is null) return;\n await _archive.EnsureContentAsync(session); // content lazy-loads from the source file\n var snapshot = session.Messages\n .Take(80)\n .Select(m => (m.RoleLabel, ArchiveService.ForReading(m.Text)))\n .ToList();\n var title = session.DisplayTitle;\n var subtitle = $\"{(string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} · {session.WorkspaceName}\";\n DispatcherQueue.TryEnqueue(() =>\n {\n try\n {\n var window = new ChatPreviewWindow(title, subtitle, snapshot);\n _previewWindows.Add(window);\n window.Closed += (_, _) => _previewWindows.Remove(window);\n window.Activate();\n Diag.Log($\"Preview window opened for \\\"{title}\\\" ({_previewWindows.Count} open)\");\n }\n catch (Exception ex) { Diag.Log(\"Preview window failed \" + ex); }\n });\n }\n\n private UIElement CopilotBubble(string who, string text, bool isUser)\n {\n var stack = new StackPanel { Spacing = 6 };\n stack.Children.Add(new TextBlock { Text = who, Foreground = MutedBrush(), FontSize = 12 });\n if (isUser)\n stack.Children.Add(new TextBlock { Text = text, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 22, FontSize = 14 });\n else\n stack.Children.Add(BuildMarkdown(text)); // assistant replies are markdown\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(16),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Child = stack\n };\n }\n\n private UIElement ToolChip(string name, string argsJson)\n {\n var text = string.IsNullOrWhiteSpace(argsJson) || argsJson == \"{}\" ? name : $\"{name} {Trim(argsJson, 80)}\";\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(36, 120, 170, 255)),\n CornerRadius = new CornerRadius(8),\n Padding = new Thickness(10, 4, 10, 4),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children =\n {\n new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 12, Glyph = \"\", Foreground = MutedBrush() },\n new TextBlock { Text = text, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center }\n }\n }\n };\n }\n\n private UIElement CopilotNote(string text) =>\n new TextBlock { Text = text, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(2, 0, 0, 0) };\n\n // Lightweight markdown -> RichTextBlock: headers, **bold**, `code`, bullets, and table rows\n // (kept monospaced). Robust to unclosed markers; not a full parser, just readable formatting.\n private RichTextBlock BuildMarkdown(string text)\n {\n var rtb = new RichTextBlock { TextWrapping = TextWrapping.Wrap, Foreground = StrongBrush(), LineHeight = 22, FontSize = 14 };\n foreach (var raw in (text ?? \"\").Replace(\"\\r\", \"\").Split('\\n'))\n {\n var line = raw;\n var para = new Microsoft.UI.Xaml.Documents.Paragraph();\n\n var t = line.Trim();\n if (t is \"---\" or \"***\" or \"___\")\n {\n para.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = \"──────────\", Foreground = MutedBrush() });\n rtb.Blocks.Add(para);\n continue;\n }\n\n var hashes = 0;\n while (hashes < line.Length && line[hashes] == '#') hashes++;\n if (hashes > 0 && hashes < line.Length && line[hashes] == ' ')\n {\n line = line[(hashes + 1)..];\n para.FontWeight = FontWeights.SemiBold;\n para.FontSize = hashes <= 2 ? 16 : 15;\n para.Margin = new Thickness(0, 8, 0, 2);\n }\n\n var trimmedStart = line.TrimStart();\n if (trimmedStart.StartsWith(\"- \") || trimmedStart.StartsWith(\"* \"))\n line = \" • \" + trimmedStart[2..];\n\n var monospace = line.Contains('|'); // table row\n AddInlineRuns(para.Inlines, line, monospace);\n rtb.Blocks.Add(para);\n }\n return rtb;\n }\n\n private void AddInlineRuns(Microsoft.UI.Xaml.Documents.InlineCollection inlines, string line, bool monospace)\n {\n var i = 0;\n while (i < line.Length)\n {\n if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '*')\n {\n var end = line.IndexOf(\"**\", i + 2, StringComparison.Ordinal);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 2)..end], FontWeight = FontWeights.Bold }); i = end + 2; continue; }\n }\n if (line[i] == '`')\n {\n var end = line.IndexOf('`', i + 1);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 1)..end], FontFamily = new FontFamily(\"Consolas\") }); i = end + 1; continue; }\n }\n var next = NextMarker(line, i);\n var run = new Microsoft.UI.Xaml.Documents.Run { Text = line[i..next] };\n if (monospace) run.FontFamily = new FontFamily(\"Consolas\");\n inlines.Add(run);\n i = next;\n }\n }\n\n private static int NextMarker(string s, int from)\n {\n for (var k = from + 1; k < s.Length; k++)\n {\n if (s[k] == '`') return k;\n if (s[k] == '*' && k + 1 < s.Length && s[k + 1] == '*') return k;\n }\n return s.Length;\n }\n}\n"}, "eae5e0cf9f6b3676162ea2101a70f7529502bc97": {"t": 1785756764.4545836, "n": 18471, "h": "ef6c357f020f8878", "c": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Threading.Tasks;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI.Text;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\n\nnamespace CodexLocalRetrieval_Native;\n\n// The co-pilot: a multi-turn chat over the archive backed by DeepSeek (key autograbbed from the\n// environment) and the narrow ArchiveToolService. Mutations route through a confirm dialog; show_chat\n// opens a floating preview window. Tool results are data — the system prompt forbids obeying them.\npublic sealed partial class MainPage\n{\n private readonly List _copilotApi = new();\n private readonly List _previewWindows = new();\n private bool _copilotBusy;\n private string _copilotStatus = \"\";\n private string _copilotBackend = \"deepseek\"; // deepseek | claude-cli\n private System.Threading.CancellationTokenSource? _copilotCts;\n\n private static readonly string[] CopilotSuggestions =\n {\n \"Summarize my most recent chat\",\n \"Find chats about VENPOD and list them\",\n \"Open my most recent chat in a preview window\",\n \"Resume my most recent chat in a terminal\",\n \"Make a restore packet for my latest chat\"\n };\n\n private void ResetCopilot()\n {\n _copilotApi.Clear();\n _copilotApi.Add(ChatMessage.System(ArchiveToolService.SystemPrompt));\n _copilotStatus = \"\";\n }\n\n private IChatBackend? BuildCopilotBackend()\n {\n if (_copilotBackend == \"claude-cli\")\n return new ClaudexBackend(ArchiveService.ResolveClaudeExe());\n\n var provider = _archive.ActiveAiProvider()\n ?? _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key)) return null;\n return new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n }\n\n private void RenderCopilot()\n {\n ScreenLabel.Text = \"Co-pilot\";\n TitleText.Text = \"Chat about your archive\";\n MainContent.Children.Clear();\n if (_copilotApi.Count == 0) ResetCopilot();\n\n MainContent.Children.Add(CopilotComposer());\n\n if (_copilotBusy) MainContent.Children.Add(CopilotNote(\"Thinking...\"));\n if (!string.IsNullOrWhiteSpace(_copilotStatus)) MainContent.Children.Add(CopilotNote(_copilotStatus));\n\n // Newest exchange first, directly under the composer, so the latest reply is visible\n // without scrolling. System + tool messages are not shown; tool calls render as chips.\n var visible = _copilotApi.Where(m => m.Role is \"user\" or \"assistant\").ToList();\n for (var i = visible.Count - 1; i >= 0; i--)\n {\n var m = visible[i];\n if (m.Role == \"user\")\n {\n MainContent.Children.Add(CopilotBubble(\"You\", m.Content ?? \"\", true));\n }\n else\n {\n if (m.ToolCalls is { Count: > 0 })\n foreach (var call in m.ToolCalls) MainContent.Children.Add(ToolChip(call.Function.Name, call.Function.Arguments));\n if (!string.IsNullOrWhiteSpace(m.Content))\n MainContent.Children.Add(CopilotBubble(\"Co-pilot\", m.Content!, false));\n }\n }\n\n if (visible.Count == 0) MainContent.Children.Add(CopilotSuggestionsCard());\n }\n\n private UIElement CopilotComposer()\n {\n var input = new TextBox\n {\n PlaceholderText = \"Ask about your chats, or tell me to organize them...\",\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 64,\n MaxHeight = 160,\n CornerRadius = ControlCornerRadius(),\n IsEnabled = !_copilotBusy\n };\n input.KeyDown += async (_, e) =>\n {\n if (e.Key == Windows.System.VirtualKey.Enter &&\n (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift) & Windows.UI.Core.CoreVirtualKeyStates.Down) == 0)\n {\n e.Handled = true;\n var text = input.Text;\n input.Text = \"\";\n await SendCopilotAsync(text);\n }\n };\n\n var send = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Send\", IsEnabled = !_copilotBusy };\n send.Click += async (_, _) => { var t = input.Text; input.Text = \"\"; await SendCopilotAsync(t); };\n\n // While a turn is running, Send is replaced by Stop so the user can abort a long round.\n var stop = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Stop\" };\n stop.Click += (_, _) => CancelCopilot();\n\n var reset = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"New chat\", IsEnabled = !_copilotBusy };\n reset.Click += (_, _) => { if (!_copilotBusy) { ResetCopilot(); RenderCopilot(); } };\n\n var backendCombo = new ComboBox { MinWidth = 140, CornerRadius = ControlCornerRadius(), VerticalAlignment = VerticalAlignment.Center, IsEnabled = !_copilotBusy };\n backendCombo.Items.Add(new ComboBoxItem { Content = \"DeepSeek\", Tag = \"deepseek\" });\n backendCombo.Items.Add(new ComboBoxItem { Content = \"Claude (CLI)\", Tag = \"claude-cli\" });\n backendCombo.SelectedIndex = _copilotBackend == \"claude-cli\" ? 1 : 0;\n backendCombo.SelectionChanged += (_, _) =>\n {\n if (backendCombo.SelectedItem is ComboBoxItem item && item.Tag is string tag && tag != _copilotBackend)\n {\n _copilotBackend = tag;\n RenderCopilot();\n }\n };\n\n var note = new TextBlock\n {\n Text = _copilotBackend == \"claude-cli\"\n ? \"Claude CLI — experimental, plain chat (no archive tools)\"\n : \"DeepSeek key: \" + ApiKeySource(\"deepseek\"),\n Foreground = MutedBrush(),\n FontSize = 11,\n VerticalAlignment = VerticalAlignment.Center,\n TextWrapping = TextWrapping.Wrap\n };\n\n var left = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12, VerticalAlignment = VerticalAlignment.Center, Children = { backendCombo, note } };\n\n var buttons = new Grid { Margin = new Thickness(0, 10, 0, 0), ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n buttons.Children.Add(left);\n var right = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };\n right.Children.Add(reset);\n right.Children.Add(_copilotBusy ? stop : send);\n Grid.SetColumn(right, 1);\n buttons.Children.Add(right);\n\n return Card(new StackPanel { Spacing = 0, Children = { input, buttons } });\n }\n\n private UIElement CopilotSuggestionsCard()\n {\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock { Text = \"Try asking\", Foreground = StrongBrush(), FontSize = 15, FontWeight = FontWeights.SemiBold });\n foreach (var s in CopilotSuggestions)\n {\n var b = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = s, HorizontalAlignment = HorizontalAlignment.Left };\n b.Click += async (_, _) => await SendCopilotAsync(s);\n stack.Children.Add(b);\n }\n return Card(stack);\n }\n\n private async Task SendCopilotAsync(string text)\n {\n if (_copilotBusy || string.IsNullOrWhiteSpace(text)) return;\n if (_copilotApi.Count == 0) ResetCopilot();\n\n var backend = BuildCopilotBackend();\n if (backend is null)\n {\n _copilotStatus = \"No DeepSeek key found. Set DEEPSEEK_API_KEY in your environment (or add a provider in Settings), then try again.\";\n RenderCopilot();\n return;\n }\n\n _copilotApi.Add(ChatMessage.User(text.Trim()));\n TrimCopilotContext();\n _copilotBusy = true;\n _copilotStatus = \"\";\n _copilotCts = new System.Threading.CancellationTokenSource();\n RenderCopilot();\n\n try\n {\n var tools = new ArchiveToolService(_archive, showChat: OpenChatPreview, resumeChat: ResumeChatFromCopilot).Tools();\n var orchestrator = new ChatOrchestrator(backend, tools, confirm: ConfirmCopilotActionAsync);\n var result = await orchestrator.RunAsync(_copilotApi, _copilotCts.Token);\n if (string.IsNullOrWhiteSpace(result.Answer) && !string.IsNullOrWhiteSpace(result.Error))\n _copilotStatus = result.Error!;\n }\n catch (OperationCanceledException)\n {\n _copilotStatus = \"Stopped.\"; // user hit Stop — not an error\n }\n catch (Exception ex)\n {\n Diag.Log(\"Copilot error \" + ex);\n _copilotStatus = \"Error talking to the model: \" + ex.Message;\n }\n finally\n {\n _copilotBusy = false;\n _copilotCts?.Dispose();\n _copilotCts = null;\n RenderCopilot();\n }\n }\n\n // Cancels the in-flight turn. The orchestrator threads this token into the backend call, so a\n // long DeepSeek round (or a retry/backoff wait) aborts promptly. Guarded because the CTS is\n // disposed+nulled in SendCopilotAsync's finally (a late click could otherwise race it).\n private void CancelCopilot()\n {\n try { _copilotCts?.Cancel(); } catch (ObjectDisposedException) { }\n }\n\n // Keep the system message + the most recent whole turns, so a long session can't grow the context\n // (or the cost) without bound. Trimming by whole user-turns avoids leaving a tool message dangling\n // without its assistant tool_call (which the API rejects).\n private void TrimCopilotContext()\n {\n const int maxTurns = 12;\n var userIndices = new List();\n for (var i = 1; i < _copilotApi.Count; i++)\n if (_copilotApi[i].Role == \"user\") userIndices.Add(i);\n if (userIndices.Count <= maxTurns) return;\n var keepFrom = userIndices[userIndices.Count - maxTurns];\n _copilotApi.RemoveRange(1, keepFrom - 1); // keep index 0 (system) + everything from keepFrom\n }\n\n private async Task ConfirmCopilotActionAsync(ChatTool tool, JsonElement args)\n {\n Diag.Log($\"Copilot confirm requested for tool: {tool.Name} {args}\");\n\n string body;\n var defaultButton = ContentDialogButton.Primary;\n if (tool.Name == \"resume_chat\")\n {\n var id = args.TryGetProperty(\"id\", out var idEl) && idEl.ValueKind == JsonValueKind.String ? idEl.GetString() ?? \"\" : \"\";\n var session = _archive.GetSession(id);\n if (session is null)\n {\n body = \"Resume a chat in a new terminal?\";\n }\n else\n {\n var launch = _archive.BuildResumeLaunch(session);\n body = $\"Open a NEW terminal and resume “{session.DisplayTitle}”?\\n\\n\" +\n \"This launches an external agent CLI:\\n\" +\n $\" {launch.DisplayCommand}\\n\" +\n $\"in {launch.WorkingDirectory}\";\n }\n defaultButton = ContentDialogButton.Close; // a terminal launch should default to Skip\n }\n else\n {\n body = $\"Let the co-pilot run “{tool.Name}”?\\n\\n{args}\\n\\nThis changes app metadata only — your chat files are never modified.\";\n }\n\n var dialog = new ContentDialog\n {\n Title = \"Allow this action?\",\n Content = body,\n PrimaryButtonText = \"Allow\",\n CloseButtonText = \"Skip\",\n DefaultButton = defaultButton,\n XamlRoot = XamlRoot\n };\n return await dialog.ShowAsync() == ContentDialogResult.Primary;\n }\n\n private void ResumeChatFromCopilot(string id)\n {\n var session = _archive.GetSession(id);\n if (session is not null) DispatcherQueue.TryEnqueue(() => ResumeInTerminal(session));\n }\n\n private async void OpenChatPreview(string id)\n {\n var session = _archive.GetSession(id);\n if (session is null) return;\n await _archive.EnsureContentAsync(session); // content lazy-loads from the source file\n var snapshot = session.Messages\n .Take(80)\n .Select(m => (m.RoleLabel, ArchiveService.ForReading(m.Text)))\n .ToList();\n var title = session.DisplayTitle;\n var subtitle = $\"{(string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} · {session.WorkspaceName}\";\n DispatcherQueue.TryEnqueue(() =>\n {\n try\n {\n var window = new ChatPreviewWindow(title, subtitle, snapshot);\n _previewWindows.Add(window);\n window.Closed += (_, _) => _previewWindows.Remove(window);\n window.Activate();\n Diag.Log($\"Preview window opened for \\\"{title}\\\" ({_previewWindows.Count} open)\");\n }\n catch (Exception ex) { Diag.Log(\"Preview window failed \" + ex); }\n });\n }\n\n private UIElement CopilotBubble(string who, string text, bool isUser)\n {\n var stack = new StackPanel { Spacing = 6 };\n stack.Children.Add(new TextBlock { Text = who, Foreground = MutedBrush(), FontSize = 12 });\n if (isUser)\n stack.Children.Add(new TextBlock { Text = text, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 22, FontSize = 14 });\n else\n stack.Children.Add(BuildMarkdown(text)); // assistant replies are markdown\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(16),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Child = stack\n };\n }\n\n private UIElement ToolChip(string name, string argsJson)\n {\n var text = string.IsNullOrWhiteSpace(argsJson) || argsJson == \"{}\" ? name : $\"{name} {Trim(argsJson, 80)}\";\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(36, 120, 170, 255)),\n CornerRadius = new CornerRadius(8),\n Padding = new Thickness(10, 4, 10, 4),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children =\n {\n new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 12, Glyph = \"\", Foreground = MutedBrush() },\n new TextBlock { Text = text, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center }\n }\n }\n };\n }\n\n private UIElement CopilotNote(string text) =>\n new TextBlock { Text = text, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(2, 0, 0, 0) };\n\n // Lightweight markdown -> RichTextBlock: headers, **bold**, `code`, bullets, and table rows\n // (kept monospaced). Robust to unclosed markers; not a full parser, just readable formatting.\n private RichTextBlock BuildMarkdown(string text)\n {\n var rtb = new RichTextBlock { TextWrapping = TextWrapping.Wrap, Foreground = StrongBrush(), LineHeight = 22, FontSize = 14 };\n foreach (var raw in (text ?? \"\").Replace(\"\\r\", \"\").Split('\\n'))\n {\n var line = raw;\n var para = new Microsoft.UI.Xaml.Documents.Paragraph();\n\n var t = line.Trim();\n if (t is \"---\" or \"***\" or \"___\")\n {\n para.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = \"──────────\", Foreground = MutedBrush() });\n rtb.Blocks.Add(para);\n continue;\n }\n\n var hashes = 0;\n while (hashes < line.Length && line[hashes] == '#') hashes++;\n if (hashes > 0 && hashes < line.Length && line[hashes] == ' ')\n {\n line = line[(hashes + 1)..];\n para.FontWeight = FontWeights.SemiBold;\n para.FontSize = hashes <= 2 ? 16 : 15;\n para.Margin = new Thickness(0, 8, 0, 2);\n }\n\n var trimmedStart = line.TrimStart();\n if (trimmedStart.StartsWith(\"- \") || trimmedStart.StartsWith(\"* \"))\n line = \" • \" + trimmedStart[2..];\n\n var monospace = line.Contains('|'); // table row\n AddInlineRuns(para.Inlines, line, monospace);\n rtb.Blocks.Add(para);\n }\n return rtb;\n }\n\n private void AddInlineRuns(Microsoft.UI.Xaml.Documents.InlineCollection inlines, string line, bool monospace)\n {\n var i = 0;\n while (i < line.Length)\n {\n if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '*')\n {\n var end = line.IndexOf(\"**\", i + 2, StringComparison.Ordinal);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 2)..end], FontWeight = FontWeights.Bold }); i = end + 2; continue; }\n }\n if (line[i] == '`')\n {\n var end = line.IndexOf('`', i + 1);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 1)..end], FontFamily = new FontFamily(\"Consolas\") }); i = end + 1; continue; }\n }\n var next = NextMarker(line, i);\n var run = new Microsoft.UI.Xaml.Documents.Run { Text = line[i..next] };\n if (monospace) run.FontFamily = new FontFamily(\"Consolas\");\n inlines.Add(run);\n i = next;\n }\n }\n\n private static int NextMarker(string s, int from)\n {\n for (var k = from + 1; k < s.Length; k++)\n {\n if (s[k] == '`') return k;\n if (s[k] == '*' && k + 1 < s.Length && s[k + 1] == '*') return k;\n }\n return s.Length;\n }\n}\n"}, "e82274ce6205e10ad64ee0c6f02a3f7c57f26ca3": {"t": 1785756765.898443, "n": 18370, "h": "37fac2dfa3095927", "c": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Threading.Tasks;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI.Text;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\n\nnamespace CodexLocalRetrieval_Native;\n\n// The co-pilot: a multi-turn chat over the archive backed by DeepSeek (key autograbbed from the\n// environment) and the narrow ArchiveToolService. Mutations route through a confirm dialog; show_chat\n// opens a floating preview window. Tool results are data — the system prompt forbids obeying them.\npublic sealed partial class MainPage\n{\n private readonly List _copilotApi = new();\n private readonly List _previewWindows = new();\n private bool _copilotBusy;\n private string _copilotStatus = \"\";\n private string _copilotBackend = \"deepseek\"; // deepseek | claude-cli\n private System.Threading.CancellationTokenSource? _copilotCts;\n\n private static readonly string[] CopilotSuggestions =\n {\n \"Summarize my most recent chat\",\n \"Find chats about VENPOD and list them\",\n \"Open my most recent chat in a preview window\",\n \"Resume my most recent chat in a terminal\",\n \"Make a restore packet for my latest chat\"\n };\n\n private void ResetCopilot()\n {\n _copilotApi.Clear();\n _copilotApi.Add(ChatMessage.System(ArchiveToolService.SystemPrompt));\n _copilotStatus = \"\";\n }\n\n private IChatBackend? BuildCopilotBackend()\n {\n if (_copilotBackend == \"claude-cli\")\n return new ClaudexBackend(ArchiveService.ResolveClaudeExe());\n\n var provider = _archive.ActiveAiProvider()\n ?? _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key)) return null;\n return new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n }\n\n private void RenderCopilot()\n {\n ScreenLabel.Text = \"Co-pilot\";\n TitleText.Text = \"Chat about your archive\";\n MainContent.Children.Clear();\n if (_copilotApi.Count == 0) ResetCopilot();\n\n MainContent.Children.Add(CopilotComposer());\n\n if (_copilotBusy) MainContent.Children.Add(CopilotNote(\"Thinking...\"));\n if (!string.IsNullOrWhiteSpace(_copilotStatus)) MainContent.Children.Add(CopilotNote(_copilotStatus));\n\n // Newest exchange first, directly under the composer, so the latest reply is visible\n // without scrolling. System + tool messages are not shown; tool calls render as chips.\n var visible = _copilotApi.Where(m => m.Role is \"user\" or \"assistant\").ToList();\n for (var i = visible.Count - 1; i >= 0; i--)\n {\n var m = visible[i];\n if (m.Role == \"user\")\n {\n MainContent.Children.Add(CopilotBubble(\"You\", m.Content ?? \"\", true));\n }\n else\n {\n if (m.ToolCalls is { Count: > 0 })\n foreach (var call in m.ToolCalls) MainContent.Children.Add(ToolChip(call.Function.Name, call.Function.Arguments));\n if (!string.IsNullOrWhiteSpace(m.Content))\n MainContent.Children.Add(CopilotBubble(\"Co-pilot\", m.Content!, false));\n }\n }\n\n if (visible.Count == 0) MainContent.Children.Add(CopilotSuggestionsCard());\n }\n\n private UIElement CopilotComposer()\n {\n var input = new TextBox\n {\n PlaceholderText = \"Ask about your chats, or tell me to organize them...\",\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 64,\n MaxHeight = 160,\n CornerRadius = ControlCornerRadius(),\n IsEnabled = !_copilotBusy\n };\n input.KeyDown += async (_, e) =>\n {\n if (e.Key == Windows.System.VirtualKey.Enter &&\n (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift) & Windows.UI.Core.CoreVirtualKeyStates.Down) == 0)\n {\n e.Handled = true;\n var text = input.Text;\n input.Text = \"\";\n await SendCopilotAsync(text);\n }\n };\n\n var send = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Send\", IsEnabled = !_copilotBusy };\n send.Click += async (_, _) => { var t = input.Text; input.Text = \"\"; await SendCopilotAsync(t); };\n\n // While a turn is running, Send is replaced by Stop so the user can abort a long round.\n var stop = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"Stop\" };\n stop.Click += (_, _) => CancelCopilot();\n\n var reset = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"New chat\", IsEnabled = !_copilotBusy };\n reset.Click += (_, _) => { if (!_copilotBusy) { ResetCopilot(); RenderCopilot(); } };\n\n var backendCombo = new ComboBox { MinWidth = 140, CornerRadius = ControlCornerRadius(), VerticalAlignment = VerticalAlignment.Center, IsEnabled = !_copilotBusy };\n backendCombo.Items.Add(new ComboBoxItem { Content = \"DeepSeek\", Tag = \"deepseek\" });\n backendCombo.Items.Add(new ComboBoxItem { Content = \"Claude (CLI)\", Tag = \"claude-cli\" });\n backendCombo.SelectedIndex = _copilotBackend == \"claude-cli\" ? 1 : 0;\n backendCombo.SelectionChanged += (_, _) =>\n {\n if (backendCombo.SelectedItem is ComboBoxItem item && item.Tag is string tag && tag != _copilotBackend)\n {\n _copilotBackend = tag;\n RenderCopilot();\n }\n };\n\n var note = new TextBlock\n {\n Text = _copilotBackend == \"claude-cli\"\n ? \"Claude CLI — experimental, plain chat (no archive tools)\"\n : \"DeepSeek key: \" + ApiKeySource(\"deepseek\"),\n Foreground = MutedBrush(),\n FontSize = 11,\n VerticalAlignment = VerticalAlignment.Center,\n TextWrapping = TextWrapping.Wrap\n };\n\n var left = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12, VerticalAlignment = VerticalAlignment.Center, Children = { backendCombo, note } };\n\n var buttons = new Grid { Margin = new Thickness(0, 10, 0, 0), ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n buttons.Children.Add(left);\n var right = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };\n right.Children.Add(reset);\n right.Children.Add(_copilotBusy ? stop : send);\n Grid.SetColumn(right, 1);\n buttons.Children.Add(right);\n\n return Card(new StackPanel { Spacing = 0, Children = { input, buttons } });\n }\n\n private UIElement CopilotSuggestionsCard()\n {\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock { Text = \"Try asking\", Foreground = StrongBrush(), FontSize = 15, FontWeight = FontWeights.SemiBold });\n foreach (var s in CopilotSuggestions)\n {\n var b = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = s, HorizontalAlignment = HorizontalAlignment.Left };\n b.Click += async (_, _) => await SendCopilotAsync(s);\n stack.Children.Add(b);\n }\n return Card(stack);\n }\n\n private async Task SendCopilotAsync(string text)\n {\n if (_copilotBusy || string.IsNullOrWhiteSpace(text)) return;\n if (_copilotApi.Count == 0) ResetCopilot();\n\n var backend = BuildCopilotBackend();\n if (backend is null)\n {\n _copilotStatus = \"No DeepSeek key found. Set DEEPSEEK_API_KEY in your environment (or add a provider in Settings), then try again.\";\n RenderCopilot();\n return;\n }\n\n _copilotApi.Add(ChatMessage.User(text.Trim()));\n TrimCopilotContext();\n _copilotBusy = true;\n _copilotStatus = \"\";\n _copilotCts = new System.Threading.CancellationTokenSource();\n RenderCopilot();\n\n try\n {\n var tools = new ArchiveToolService(_archive, showChat: OpenChatPreview, resumeChat: ResumeChatFromCopilot).Tools();\n var orchestrator = new ChatOrchestrator(backend, tools, confirm: ConfirmCopilotActionAsync);\n var result = await orchestrator.RunAsync(_copilotApi, _copilotCts.Token);\n if (string.IsNullOrWhiteSpace(result.Answer) && !string.IsNullOrWhiteSpace(result.Error))\n _copilotStatus = result.Error!;\n }\n catch (OperationCanceledException)\n {\n _copilotStatus = \"Stopped.\"; // user hit Stop — not an error\n }\n catch (Exception ex)\n {\n Diag.Log(\"Copilot error \" + ex);\n _copilotStatus = \"Error talking to the model: \" + ex.Message;\n }\n finally\n {\n _copilotBusy = false;\n _copilotCts?.Dispose();\n _copilotCts = null;\n RenderCopilot();\n }\n }\n\n // Cancels the in-flight turn. The orchestrator threads this token into the backend call, so a\n // long DeepSeek round (or a retry/backoff wait) aborts promptly. Guarded because the CTS is\n // disposed+nulled in SendCopilotAsync's finally (a late click could otherwise race it).\n private void CancelCopilot()\n {\n try { _copilotCts?.Cancel(); } catch (ObjectDisposedException) { }\n }\n\n // Keep the system message + the most recent whole turns, so a long session can't grow the context\n // (or the cost) without bound. Trimming by whole user-turns avoids leaving a tool message dangling\n // without its assistant tool_call (which the API rejects).\n private void TrimCopilotContext()\n {\n const int maxTurns = 12;\n var userIndices = new List();\n for (var i = 1; i < _copilotApi.Count; i++)\n if (_copilotApi[i].Role == \"user\") userIndices.Add(i);\n if (userIndices.Count <= maxTurns) return;\n var keepFrom = userIndices[userIndices.Count - maxTurns];\n _copilotApi.RemoveRange(1, keepFrom - 1); // keep index 0 (system) + everything from keepFrom\n }\n\n private async Task ConfirmCopilotActionAsync(ChatTool tool, JsonElement args)\n {\n Diag.Log($\"Copilot confirm requested for tool: {tool.Name} {args}\");\n\n string body;\n var defaultButton = ContentDialogButton.Primary;\n if (tool.Name == \"resume_chat\")\n {\n var id = args.TryGetProperty(\"id\", out var idEl) && idEl.ValueKind == JsonValueKind.String ? idEl.GetString() ?? \"\" : \"\";\n var session = _archive.GetSession(id);\n if (session is null)\n {\n body = \"Resume a chat in a new terminal?\";\n }\n else\n {\n var launch = _archive.BuildResumeLaunch(session);\n body = $\"Open a NEW terminal and resume “{session.DisplayTitle}”?\\n\\n\" +\n \"This launches an external agent CLI:\\n\" +\n $\" {launch.DisplayCommand}\\n\" +\n $\"in {launch.WorkingDirectory}\";\n }\n defaultButton = ContentDialogButton.Close; // a terminal launch should default to Skip\n }\n else\n {\n body = $\"Let the co-pilot run “{tool.Name}”?\\n\\n{args}\\n\\nThis changes app metadata only — your chat files are never modified.\";\n }\n\n var dialog = new ContentDialog\n {\n Title = \"Allow this action?\",\n Content = body,\n PrimaryButtonText = \"Allow\",\n CloseButtonText = \"Skip\",\n DefaultButton = defaultButton,\n XamlRoot = XamlRoot\n };\n return await dialog.ShowAsync() == ContentDialogResult.Primary;\n }\n\n private void ResumeChatFromCopilot(string id)\n {\n var session = _archive.GetSession(id);\n if (session is not null) DispatcherQueue.TryEnqueue(() => ResumeInTerminal(session));\n }\n\n private void OpenChatPreview(string id)\n {\n var session = _archive.GetSession(id);\n if (session is null) return;\n var snapshot = session.Messages\n .Take(80)\n .Select(m => (m.RoleLabel, ArchiveService.ForReading(m.Text)))\n .ToList();\n var title = session.DisplayTitle;\n var subtitle = $\"{(string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} · {session.WorkspaceName}\";\n DispatcherQueue.TryEnqueue(() =>\n {\n try\n {\n var window = new ChatPreviewWindow(title, subtitle, snapshot);\n _previewWindows.Add(window);\n window.Closed += (_, _) => _previewWindows.Remove(window);\n window.Activate();\n Diag.Log($\"Preview window opened for \\\"{title}\\\" ({_previewWindows.Count} open)\");\n }\n catch (Exception ex) { Diag.Log(\"Preview window failed \" + ex); }\n });\n }\n\n private UIElement CopilotBubble(string who, string text, bool isUser)\n {\n var stack = new StackPanel { Spacing = 6 };\n stack.Children.Add(new TextBlock { Text = who, Foreground = MutedBrush(), FontSize = 12 });\n if (isUser)\n stack.Children.Add(new TextBlock { Text = text, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 22, FontSize = 14 });\n else\n stack.Children.Add(BuildMarkdown(text)); // assistant replies are markdown\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(16),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Child = stack\n };\n }\n\n private UIElement ToolChip(string name, string argsJson)\n {\n var text = string.IsNullOrWhiteSpace(argsJson) || argsJson == \"{}\" ? name : $\"{name} {Trim(argsJson, 80)}\";\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(36, 120, 170, 255)),\n CornerRadius = new CornerRadius(8),\n Padding = new Thickness(10, 4, 10, 4),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children =\n {\n new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 12, Glyph = \"\", Foreground = MutedBrush() },\n new TextBlock { Text = text, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center }\n }\n }\n };\n }\n\n private UIElement CopilotNote(string text) =>\n new TextBlock { Text = text, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(2, 0, 0, 0) };\n\n // Lightweight markdown -> RichTextBlock: headers, **bold**, `code`, bullets, and table rows\n // (kept monospaced). Robust to unclosed markers; not a full parser, just readable formatting.\n private RichTextBlock BuildMarkdown(string text)\n {\n var rtb = new RichTextBlock { TextWrapping = TextWrapping.Wrap, Foreground = StrongBrush(), LineHeight = 22, FontSize = 14 };\n foreach (var raw in (text ?? \"\").Replace(\"\\r\", \"\").Split('\\n'))\n {\n var line = raw;\n var para = new Microsoft.UI.Xaml.Documents.Paragraph();\n\n var t = line.Trim();\n if (t is \"---\" or \"***\" or \"___\")\n {\n para.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = \"──────────\", Foreground = MutedBrush() });\n rtb.Blocks.Add(para);\n continue;\n }\n\n var hashes = 0;\n while (hashes < line.Length && line[hashes] == '#') hashes++;\n if (hashes > 0 && hashes < line.Length && line[hashes] == ' ')\n {\n line = line[(hashes + 1)..];\n para.FontWeight = FontWeights.SemiBold;\n para.FontSize = hashes <= 2 ? 16 : 15;\n para.Margin = new Thickness(0, 8, 0, 2);\n }\n\n var trimmedStart = line.TrimStart();\n if (trimmedStart.StartsWith(\"- \") || trimmedStart.StartsWith(\"* \"))\n line = \" • \" + trimmedStart[2..];\n\n var monospace = line.Contains('|'); // table row\n AddInlineRuns(para.Inlines, line, monospace);\n rtb.Blocks.Add(para);\n }\n return rtb;\n }\n\n private void AddInlineRuns(Microsoft.UI.Xaml.Documents.InlineCollection inlines, string line, bool monospace)\n {\n var i = 0;\n while (i < line.Length)\n {\n if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '*')\n {\n var end = line.IndexOf(\"**\", i + 2, StringComparison.Ordinal);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 2)..end], FontWeight = FontWeights.Bold }); i = end + 2; continue; }\n }\n if (line[i] == '`')\n {\n var end = line.IndexOf('`', i + 1);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 1)..end], FontFamily = new FontFamily(\"Consolas\") }); i = end + 1; continue; }\n }\n var next = NextMarker(line, i);\n var run = new Microsoft.UI.Xaml.Documents.Run { Text = line[i..next] };\n if (monospace) run.FontFamily = new FontFamily(\"Consolas\");\n inlines.Add(run);\n i = next;\n }\n }\n\n private static int NextMarker(string s, int from)\n {\n for (var k = from + 1; k < s.Length; k++)\n {\n if (s[k] == '`') return k;\n if (s[k] == '*' && k + 1 < s.Length && s[k + 1] == '*') return k;\n }\n return s.Length;\n }\n}\n"}, "f29db3ee7c67010550d2cfcda885fd5bcd98c3a3": {"t": 1785756767.3938181, "n": 17334, "h": "9fd15354c59fe09d", "c": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Threading.Tasks;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI.Text;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\n\nnamespace CodexLocalRetrieval_Native;\n\n// The co-pilot: a multi-turn chat over the archive backed by DeepSeek (key autograbbed from the\n// environment) and the narrow ArchiveToolService. Mutations route through a confirm dialog; show_chat\n// opens a floating preview window. Tool results are data — the system prompt forbids obeying them.\npublic sealed partial class MainPage\n{\n private readonly List _copilotApi = new();\n private readonly List _previewWindows = new();\n private bool _copilotBusy;\n private string _copilotStatus = \"\";\n private string _copilotBackend = \"deepseek\"; // deepseek | claude-cli\n\n private static readonly string[] CopilotSuggestions =\n {\n \"Summarize my most recent chat\",\n \"Find chats about VENPOD and list them\",\n \"Open my most recent chat in a preview window\",\n \"Resume my most recent chat in a terminal\",\n \"Make a restore packet for my latest chat\"\n };\n\n private void ResetCopilot()\n {\n _copilotApi.Clear();\n _copilotApi.Add(ChatMessage.System(ArchiveToolService.SystemPrompt));\n _copilotStatus = \"\";\n }\n\n private IChatBackend? BuildCopilotBackend()\n {\n if (_copilotBackend == \"claude-cli\")\n return new ClaudexBackend(ArchiveService.ResolveClaudeExe());\n\n var provider = _archive.ActiveAiProvider()\n ?? _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key)) return null;\n return new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n }\n\n private void RenderCopilot()\n {\n ScreenLabel.Text = \"Co-pilot\";\n TitleText.Text = \"Chat about your archive\";\n MainContent.Children.Clear();\n if (_copilotApi.Count == 0) ResetCopilot();\n\n MainContent.Children.Add(CopilotComposer());\n\n if (_copilotBusy) MainContent.Children.Add(CopilotNote(\"Thinking...\"));\n if (!string.IsNullOrWhiteSpace(_copilotStatus)) MainContent.Children.Add(CopilotNote(_copilotStatus));\n\n // Newest exchange first, directly under the composer, so the latest reply is visible\n // without scrolling. System + tool messages are not shown; tool calls render as chips.\n var visible = _copilotApi.Where(m => m.Role is \"user\" or \"assistant\").ToList();\n for (var i = visible.Count - 1; i >= 0; i--)\n {\n var m = visible[i];\n if (m.Role == \"user\")\n {\n MainContent.Children.Add(CopilotBubble(\"You\", m.Content ?? \"\", true));\n }\n else\n {\n if (m.ToolCalls is { Count: > 0 })\n foreach (var call in m.ToolCalls) MainContent.Children.Add(ToolChip(call.Function.Name, call.Function.Arguments));\n if (!string.IsNullOrWhiteSpace(m.Content))\n MainContent.Children.Add(CopilotBubble(\"Co-pilot\", m.Content!, false));\n }\n }\n\n if (visible.Count == 0) MainContent.Children.Add(CopilotSuggestionsCard());\n }\n\n private UIElement CopilotComposer()\n {\n var input = new TextBox\n {\n PlaceholderText = \"Ask about your chats, or tell me to organize them...\",\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 64,\n MaxHeight = 160,\n CornerRadius = ControlCornerRadius(),\n IsEnabled = !_copilotBusy\n };\n input.KeyDown += async (_, e) =>\n {\n if (e.Key == Windows.System.VirtualKey.Enter &&\n (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift) & Windows.UI.Core.CoreVirtualKeyStates.Down) == 0)\n {\n e.Handled = true;\n var text = input.Text;\n input.Text = \"\";\n await SendCopilotAsync(text);\n }\n };\n\n var send = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Send\", IsEnabled = !_copilotBusy };\n send.Click += async (_, _) => { var t = input.Text; input.Text = \"\"; await SendCopilotAsync(t); };\n\n var reset = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"New chat\", IsEnabled = !_copilotBusy };\n reset.Click += (_, _) => { if (!_copilotBusy) { ResetCopilot(); RenderCopilot(); } };\n\n var backendCombo = new ComboBox { MinWidth = 140, CornerRadius = ControlCornerRadius(), VerticalAlignment = VerticalAlignment.Center, IsEnabled = !_copilotBusy };\n backendCombo.Items.Add(new ComboBoxItem { Content = \"DeepSeek\", Tag = \"deepseek\" });\n backendCombo.Items.Add(new ComboBoxItem { Content = \"Claude (CLI)\", Tag = \"claude-cli\" });\n backendCombo.SelectedIndex = _copilotBackend == \"claude-cli\" ? 1 : 0;\n backendCombo.SelectionChanged += (_, _) =>\n {\n if (backendCombo.SelectedItem is ComboBoxItem item && item.Tag is string tag && tag != _copilotBackend)\n {\n _copilotBackend = tag;\n RenderCopilot();\n }\n };\n\n var note = new TextBlock\n {\n Text = _copilotBackend == \"claude-cli\"\n ? \"Claude CLI — experimental, plain chat (no archive tools)\"\n : \"DeepSeek key: \" + ApiKeySource(\"deepseek\"),\n Foreground = MutedBrush(),\n FontSize = 11,\n VerticalAlignment = VerticalAlignment.Center,\n TextWrapping = TextWrapping.Wrap\n };\n\n var left = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12, VerticalAlignment = VerticalAlignment.Center, Children = { backendCombo, note } };\n\n var buttons = new Grid { Margin = new Thickness(0, 10, 0, 0), ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n buttons.Children.Add(left);\n var right = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };\n right.Children.Add(reset);\n right.Children.Add(send);\n Grid.SetColumn(right, 1);\n buttons.Children.Add(right);\n\n return Card(new StackPanel { Spacing = 0, Children = { input, buttons } });\n }\n\n private UIElement CopilotSuggestionsCard()\n {\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock { Text = \"Try asking\", Foreground = StrongBrush(), FontSize = 15, FontWeight = FontWeights.SemiBold });\n foreach (var s in CopilotSuggestions)\n {\n var b = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = s, HorizontalAlignment = HorizontalAlignment.Left };\n b.Click += async (_, _) => await SendCopilotAsync(s);\n stack.Children.Add(b);\n }\n return Card(stack);\n }\n\n private async Task SendCopilotAsync(string text)\n {\n if (_copilotBusy || string.IsNullOrWhiteSpace(text)) return;\n if (_copilotApi.Count == 0) ResetCopilot();\n\n var backend = BuildCopilotBackend();\n if (backend is null)\n {\n _copilotStatus = \"No DeepSeek key found. Set DEEPSEEK_API_KEY in your environment (or add a provider in Settings), then try again.\";\n RenderCopilot();\n return;\n }\n\n _copilotApi.Add(ChatMessage.User(text.Trim()));\n TrimCopilotContext();\n _copilotBusy = true;\n _copilotStatus = \"\";\n RenderCopilot();\n\n try\n {\n var tools = new ArchiveToolService(_archive, showChat: OpenChatPreview, resumeChat: ResumeChatFromCopilot).Tools();\n var orchestrator = new ChatOrchestrator(backend, tools, confirm: ConfirmCopilotActionAsync);\n var result = await orchestrator.RunAsync(_copilotApi);\n if (string.IsNullOrWhiteSpace(result.Answer) && !string.IsNullOrWhiteSpace(result.Error))\n _copilotStatus = result.Error!;\n }\n catch (Exception ex)\n {\n Diag.Log(\"Copilot error \" + ex);\n _copilotStatus = \"Error talking to the model: \" + ex.Message;\n }\n finally\n {\n _copilotBusy = false;\n RenderCopilot();\n }\n }\n\n // Keep the system message + the most recent whole turns, so a long session can't grow the context\n // (or the cost) without bound. Trimming by whole user-turns avoids leaving a tool message dangling\n // without its assistant tool_call (which the API rejects).\n private void TrimCopilotContext()\n {\n const int maxTurns = 12;\n var userIndices = new List();\n for (var i = 1; i < _copilotApi.Count; i++)\n if (_copilotApi[i].Role == \"user\") userIndices.Add(i);\n if (userIndices.Count <= maxTurns) return;\n var keepFrom = userIndices[userIndices.Count - maxTurns];\n _copilotApi.RemoveRange(1, keepFrom - 1); // keep index 0 (system) + everything from keepFrom\n }\n\n private async Task ConfirmCopilotActionAsync(ChatTool tool, JsonElement args)\n {\n Diag.Log($\"Copilot confirm requested for tool: {tool.Name} {args}\");\n\n string body;\n var defaultButton = ContentDialogButton.Primary;\n if (tool.Name == \"resume_chat\")\n {\n var id = args.TryGetProperty(\"id\", out var idEl) && idEl.ValueKind == JsonValueKind.String ? idEl.GetString() ?? \"\" : \"\";\n var session = _archive.GetSession(id);\n if (session is null)\n {\n body = \"Resume a chat in a new terminal?\";\n }\n else\n {\n var launch = _archive.BuildResumeLaunch(session);\n body = $\"Open a NEW terminal and resume “{session.DisplayTitle}”?\\n\\n\" +\n \"This launches an external agent CLI:\\n\" +\n $\" {launch.DisplayCommand}\\n\" +\n $\"in {launch.WorkingDirectory}\";\n }\n defaultButton = ContentDialogButton.Close; // a terminal launch should default to Skip\n }\n else\n {\n body = $\"Let the co-pilot run “{tool.Name}”?\\n\\n{args}\\n\\nThis changes app metadata only — your chat files are never modified.\";\n }\n\n var dialog = new ContentDialog\n {\n Title = \"Allow this action?\",\n Content = body,\n PrimaryButtonText = \"Allow\",\n CloseButtonText = \"Skip\",\n DefaultButton = defaultButton,\n XamlRoot = XamlRoot\n };\n return await dialog.ShowAsync() == ContentDialogResult.Primary;\n }\n\n private void ResumeChatFromCopilot(string id)\n {\n var session = _archive.GetSession(id);\n if (session is not null) DispatcherQueue.TryEnqueue(() => ResumeInTerminal(session));\n }\n\n private void OpenChatPreview(string id)\n {\n var session = _archive.GetSession(id);\n if (session is null) return;\n var snapshot = session.Messages\n .Take(80)\n .Select(m => (m.RoleLabel, ArchiveService.ForReading(m.Text)))\n .ToList();\n var title = session.DisplayTitle;\n var subtitle = $\"{(string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} · {session.WorkspaceName}\";\n DispatcherQueue.TryEnqueue(() =>\n {\n try\n {\n var window = new ChatPreviewWindow(title, subtitle, snapshot);\n _previewWindows.Add(window);\n window.Closed += (_, _) => _previewWindows.Remove(window);\n window.Activate();\n Diag.Log($\"Preview window opened for \\\"{title}\\\" ({_previewWindows.Count} open)\");\n }\n catch (Exception ex) { Diag.Log(\"Preview window failed \" + ex); }\n });\n }\n\n private UIElement CopilotBubble(string who, string text, bool isUser)\n {\n var stack = new StackPanel { Spacing = 6 };\n stack.Children.Add(new TextBlock { Text = who, Foreground = MutedBrush(), FontSize = 12 });\n if (isUser)\n stack.Children.Add(new TextBlock { Text = text, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 22, FontSize = 14 });\n else\n stack.Children.Add(BuildMarkdown(text)); // assistant replies are markdown\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(16),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Child = stack\n };\n }\n\n private UIElement ToolChip(string name, string argsJson)\n {\n var text = string.IsNullOrWhiteSpace(argsJson) || argsJson == \"{}\" ? name : $\"{name} {Trim(argsJson, 80)}\";\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(36, 120, 170, 255)),\n CornerRadius = new CornerRadius(8),\n Padding = new Thickness(10, 4, 10, 4),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children =\n {\n new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 12, Glyph = \"\", Foreground = MutedBrush() },\n new TextBlock { Text = text, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center }\n }\n }\n };\n }\n\n private UIElement CopilotNote(string text) =>\n new TextBlock { Text = text, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(2, 0, 0, 0) };\n\n // Lightweight markdown -> RichTextBlock: headers, **bold**, `code`, bullets, and table rows\n // (kept monospaced). Robust to unclosed markers; not a full parser, just readable formatting.\n private RichTextBlock BuildMarkdown(string text)\n {\n var rtb = new RichTextBlock { TextWrapping = TextWrapping.Wrap, Foreground = StrongBrush(), LineHeight = 22, FontSize = 14 };\n foreach (var raw in (text ?? \"\").Replace(\"\\r\", \"\").Split('\\n'))\n {\n var line = raw;\n var para = new Microsoft.UI.Xaml.Documents.Paragraph();\n\n var t = line.Trim();\n if (t is \"---\" or \"***\" or \"___\")\n {\n para.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = \"──────────\", Foreground = MutedBrush() });\n rtb.Blocks.Add(para);\n continue;\n }\n\n var hashes = 0;\n while (hashes < line.Length && line[hashes] == '#') hashes++;\n if (hashes > 0 && hashes < line.Length && line[hashes] == ' ')\n {\n line = line[(hashes + 1)..];\n para.FontWeight = FontWeights.SemiBold;\n para.FontSize = hashes <= 2 ? 16 : 15;\n para.Margin = new Thickness(0, 8, 0, 2);\n }\n\n var trimmedStart = line.TrimStart();\n if (trimmedStart.StartsWith(\"- \") || trimmedStart.StartsWith(\"* \"))\n line = \" • \" + trimmedStart[2..];\n\n var monospace = line.Contains('|'); // table row\n AddInlineRuns(para.Inlines, line, monospace);\n rtb.Blocks.Add(para);\n }\n return rtb;\n }\n\n private void AddInlineRuns(Microsoft.UI.Xaml.Documents.InlineCollection inlines, string line, bool monospace)\n {\n var i = 0;\n while (i < line.Length)\n {\n if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '*')\n {\n var end = line.IndexOf(\"**\", i + 2, StringComparison.Ordinal);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 2)..end], FontWeight = FontWeights.Bold }); i = end + 2; continue; }\n }\n if (line[i] == '`')\n {\n var end = line.IndexOf('`', i + 1);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 1)..end], FontFamily = new FontFamily(\"Consolas\") }); i = end + 1; continue; }\n }\n var next = NextMarker(line, i);\n var run = new Microsoft.UI.Xaml.Documents.Run { Text = line[i..next] };\n if (monospace) run.FontFamily = new FontFamily(\"Consolas\");\n inlines.Add(run);\n i = next;\n }\n }\n\n private static int NextMarker(string s, int from)\n {\n for (var k = from + 1; k < s.Length; k++)\n {\n if (s[k] == '`') return k;\n if (s[k] == '*' && k + 1 < s.Length && s[k + 1] == '*') return k;\n }\n return s.Length;\n }\n}\n"}, "5ab73a3a3bed6b43797d2d44c91b109ccd21db95": {"t": 1785756769.2960513, "n": 15559, "h": "101a3fe68ddc6bf4", "c": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Threading.Tasks;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI.Text;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\n\nnamespace CodexLocalRetrieval_Native;\n\n// The co-pilot: a multi-turn chat over the archive backed by DeepSeek (key autograbbed from the\n// environment) and the narrow ArchiveToolService. Mutations route through a confirm dialog; show_chat\n// opens a floating preview window. Tool results are data — the system prompt forbids obeying them.\npublic sealed partial class MainPage\n{\n private readonly List _copilotApi = new();\n private readonly List _previewWindows = new();\n private bool _copilotBusy;\n private string _copilotStatus = \"\";\n private string _copilotBackend = \"deepseek\"; // deepseek | claude-cli\n\n private static readonly string[] CopilotSuggestions =\n {\n \"Summarize my most recent chat\",\n \"Find chats about VENPOD and list them\",\n \"Open my most recent chat in a preview window\",\n \"Resume my most recent chat in a terminal\",\n \"Make a restore packet for my latest chat\"\n };\n\n private void ResetCopilot()\n {\n _copilotApi.Clear();\n _copilotApi.Add(ChatMessage.System(ArchiveToolService.SystemPrompt));\n _copilotStatus = \"\";\n }\n\n private IChatBackend? BuildCopilotBackend()\n {\n if (_copilotBackend == \"claude-cli\")\n return new ClaudexBackend(ArchiveService.ResolveClaudeExe());\n\n var provider = _archive.ActiveAiProvider()\n ?? _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key)) return null;\n return new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n }\n\n private void RenderCopilot()\n {\n ScreenLabel.Text = \"Co-pilot\";\n TitleText.Text = \"Chat about your archive\";\n MainContent.Children.Clear();\n if (_copilotApi.Count == 0) ResetCopilot();\n\n MainContent.Children.Add(CopilotComposer());\n\n if (_copilotBusy) MainContent.Children.Add(CopilotNote(\"Thinking...\"));\n if (!string.IsNullOrWhiteSpace(_copilotStatus)) MainContent.Children.Add(CopilotNote(_copilotStatus));\n\n // Newest exchange first, directly under the composer, so the latest reply is visible\n // without scrolling. System + tool messages are not shown; tool calls render as chips.\n var visible = _copilotApi.Where(m => m.Role is \"user\" or \"assistant\").ToList();\n for (var i = visible.Count - 1; i >= 0; i--)\n {\n var m = visible[i];\n if (m.Role == \"user\")\n {\n MainContent.Children.Add(CopilotBubble(\"You\", m.Content ?? \"\", true));\n }\n else\n {\n if (m.ToolCalls is { Count: > 0 })\n foreach (var call in m.ToolCalls) MainContent.Children.Add(ToolChip(call.Function.Name, call.Function.Arguments));\n if (!string.IsNullOrWhiteSpace(m.Content))\n MainContent.Children.Add(CopilotBubble(\"Co-pilot\", m.Content!, false));\n }\n }\n\n if (visible.Count == 0) MainContent.Children.Add(CopilotSuggestionsCard());\n }\n\n private UIElement CopilotComposer()\n {\n var input = new TextBox\n {\n PlaceholderText = \"Ask about your chats, or tell me to organize them...\",\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 64,\n MaxHeight = 160,\n CornerRadius = ControlCornerRadius(),\n IsEnabled = !_copilotBusy\n };\n input.KeyDown += async (_, e) =>\n {\n if (e.Key == Windows.System.VirtualKey.Enter &&\n (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift) & Windows.UI.Core.CoreVirtualKeyStates.Down) == 0)\n {\n e.Handled = true;\n var text = input.Text;\n input.Text = \"\";\n await SendCopilotAsync(text);\n }\n };\n\n var send = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Send\", IsEnabled = !_copilotBusy };\n send.Click += async (_, _) => { var t = input.Text; input.Text = \"\"; await SendCopilotAsync(t); };\n\n var reset = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"New chat\" };\n reset.Click += (_, _) => { ResetCopilot(); RenderCopilot(); };\n\n var backendCombo = new ComboBox { MinWidth = 140, CornerRadius = ControlCornerRadius(), VerticalAlignment = VerticalAlignment.Center, IsEnabled = !_copilotBusy };\n backendCombo.Items.Add(new ComboBoxItem { Content = \"DeepSeek\", Tag = \"deepseek\" });\n backendCombo.Items.Add(new ComboBoxItem { Content = \"Claude (CLI)\", Tag = \"claude-cli\" });\n backendCombo.SelectedIndex = _copilotBackend == \"claude-cli\" ? 1 : 0;\n backendCombo.SelectionChanged += (_, _) =>\n {\n if (backendCombo.SelectedItem is ComboBoxItem item && item.Tag is string tag && tag != _copilotBackend)\n {\n _copilotBackend = tag;\n RenderCopilot();\n }\n };\n\n var note = new TextBlock\n {\n Text = _copilotBackend == \"claude-cli\"\n ? \"Claude CLI — experimental, plain chat (no archive tools)\"\n : \"DeepSeek key: \" + ApiKeySource(\"deepseek\"),\n Foreground = MutedBrush(),\n FontSize = 11,\n VerticalAlignment = VerticalAlignment.Center,\n TextWrapping = TextWrapping.Wrap\n };\n\n var left = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12, VerticalAlignment = VerticalAlignment.Center, Children = { backendCombo, note } };\n\n var buttons = new Grid { Margin = new Thickness(0, 10, 0, 0), ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n buttons.Children.Add(left);\n var right = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };\n right.Children.Add(reset);\n right.Children.Add(send);\n Grid.SetColumn(right, 1);\n buttons.Children.Add(right);\n\n return Card(new StackPanel { Spacing = 0, Children = { input, buttons } });\n }\n\n private UIElement CopilotSuggestionsCard()\n {\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock { Text = \"Try asking\", Foreground = StrongBrush(), FontSize = 15, FontWeight = FontWeights.SemiBold });\n foreach (var s in CopilotSuggestions)\n {\n var b = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = s, HorizontalAlignment = HorizontalAlignment.Left };\n b.Click += async (_, _) => await SendCopilotAsync(s);\n stack.Children.Add(b);\n }\n return Card(stack);\n }\n\n private async Task SendCopilotAsync(string text)\n {\n if (_copilotBusy || string.IsNullOrWhiteSpace(text)) return;\n if (_copilotApi.Count == 0) ResetCopilot();\n\n var backend = BuildCopilotBackend();\n if (backend is null)\n {\n _copilotStatus = \"No DeepSeek key found. Set DEEPSEEK_API_KEY in your environment (or add a provider in Settings), then try again.\";\n RenderCopilot();\n return;\n }\n\n _copilotApi.Add(ChatMessage.User(text.Trim()));\n _copilotBusy = true;\n _copilotStatus = \"\";\n RenderCopilot();\n\n try\n {\n var tools = new ArchiveToolService(_archive, showChat: OpenChatPreview, resumeChat: ResumeChatFromCopilot).Tools();\n var orchestrator = new ChatOrchestrator(backend, tools, confirm: ConfirmCopilotActionAsync);\n var result = await orchestrator.RunAsync(_copilotApi);\n if (string.IsNullOrWhiteSpace(result.Answer) && !string.IsNullOrWhiteSpace(result.Error))\n _copilotStatus = result.Error!;\n }\n catch (Exception ex)\n {\n Diag.Log(\"Copilot error \" + ex);\n _copilotStatus = \"Error talking to the model: \" + ex.Message;\n }\n finally\n {\n _copilotBusy = false;\n RenderCopilot();\n }\n }\n\n private async Task ConfirmCopilotActionAsync(ChatTool tool, JsonElement args)\n {\n Diag.Log($\"Copilot confirm requested for tool: {tool.Name} {args}\");\n var dialog = new ContentDialog\n {\n Title = \"Allow this action?\",\n Content = $\"The co-pilot wants to run \\\"{tool.Name}\\\":\\n\\n{args}\\n\\nThis changes app metadata only — your chat files are never modified.\",\n PrimaryButtonText = \"Allow\",\n CloseButtonText = \"Skip\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n return await dialog.ShowAsync() == ContentDialogResult.Primary;\n }\n\n private void ResumeChatFromCopilot(string id)\n {\n var session = _archive.GetSession(id);\n if (session is not null) DispatcherQueue.TryEnqueue(() => ResumeInTerminal(session));\n }\n\n private void OpenChatPreview(string id)\n {\n var session = _archive.GetSession(id);\n if (session is null) return;\n var snapshot = session.Messages\n .Take(80)\n .Select(m => (m.RoleLabel, ArchiveService.ForReading(m.Text)))\n .ToList();\n var title = session.DisplayTitle;\n var subtitle = $\"{(string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} · {session.WorkspaceName}\";\n DispatcherQueue.TryEnqueue(() =>\n {\n try\n {\n var window = new ChatPreviewWindow(title, subtitle, snapshot);\n _previewWindows.Add(window);\n window.Closed += (_, _) => _previewWindows.Remove(window);\n window.Activate();\n Diag.Log($\"Preview window opened for \\\"{title}\\\" ({_previewWindows.Count} open)\");\n }\n catch (Exception ex) { Diag.Log(\"Preview window failed \" + ex); }\n });\n }\n\n private UIElement CopilotBubble(string who, string text, bool isUser)\n {\n var stack = new StackPanel { Spacing = 6 };\n stack.Children.Add(new TextBlock { Text = who, Foreground = MutedBrush(), FontSize = 12 });\n if (isUser)\n stack.Children.Add(new TextBlock { Text = text, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 22, FontSize = 14 });\n else\n stack.Children.Add(BuildMarkdown(text)); // assistant replies are markdown\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(16),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Child = stack\n };\n }\n\n private UIElement ToolChip(string name, string argsJson)\n {\n var text = string.IsNullOrWhiteSpace(argsJson) || argsJson == \"{}\" ? name : $\"{name} {Trim(argsJson, 80)}\";\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(36, 120, 170, 255)),\n CornerRadius = new CornerRadius(8),\n Padding = new Thickness(10, 4, 10, 4),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children =\n {\n new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 12, Glyph = \"\", Foreground = MutedBrush() },\n new TextBlock { Text = text, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center }\n }\n }\n };\n }\n\n private UIElement CopilotNote(string text) =>\n new TextBlock { Text = text, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(2, 0, 0, 0) };\n\n // Lightweight markdown -> RichTextBlock: headers, **bold**, `code`, bullets, and table rows\n // (kept monospaced). Robust to unclosed markers; not a full parser, just readable formatting.\n private RichTextBlock BuildMarkdown(string text)\n {\n var rtb = new RichTextBlock { TextWrapping = TextWrapping.Wrap, Foreground = StrongBrush(), LineHeight = 22, FontSize = 14 };\n foreach (var raw in (text ?? \"\").Replace(\"\\r\", \"\").Split('\\n'))\n {\n var line = raw;\n var para = new Microsoft.UI.Xaml.Documents.Paragraph();\n\n var t = line.Trim();\n if (t is \"---\" or \"***\" or \"___\")\n {\n para.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = \"──────────\", Foreground = MutedBrush() });\n rtb.Blocks.Add(para);\n continue;\n }\n\n var hashes = 0;\n while (hashes < line.Length && line[hashes] == '#') hashes++;\n if (hashes > 0 && hashes < line.Length && line[hashes] == ' ')\n {\n line = line[(hashes + 1)..];\n para.FontWeight = FontWeights.SemiBold;\n para.FontSize = hashes <= 2 ? 16 : 15;\n para.Margin = new Thickness(0, 8, 0, 2);\n }\n\n var trimmedStart = line.TrimStart();\n if (trimmedStart.StartsWith(\"- \") || trimmedStart.StartsWith(\"* \"))\n line = \" • \" + trimmedStart[2..];\n\n var monospace = line.Contains('|'); // table row\n AddInlineRuns(para.Inlines, line, monospace);\n rtb.Blocks.Add(para);\n }\n return rtb;\n }\n\n private void AddInlineRuns(Microsoft.UI.Xaml.Documents.InlineCollection inlines, string line, bool monospace)\n {\n var i = 0;\n while (i < line.Length)\n {\n if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '*')\n {\n var end = line.IndexOf(\"**\", i + 2, StringComparison.Ordinal);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 2)..end], FontWeight = FontWeights.Bold }); i = end + 2; continue; }\n }\n if (line[i] == '`')\n {\n var end = line.IndexOf('`', i + 1);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 1)..end], FontFamily = new FontFamily(\"Consolas\") }); i = end + 1; continue; }\n }\n var next = NextMarker(line, i);\n var run = new Microsoft.UI.Xaml.Documents.Run { Text = line[i..next] };\n if (monospace) run.FontFamily = new FontFamily(\"Consolas\");\n inlines.Add(run);\n i = next;\n }\n }\n\n private static int NextMarker(string s, int from)\n {\n for (var k = from + 1; k < s.Length; k++)\n {\n if (s[k] == '`') return k;\n if (s[k] == '*' && k + 1 < s.Length && s[k + 1] == '*') return k;\n }\n return s.Length;\n }\n}\n"}, "08fedd513748c2ec7a8b540886b6d15b0b3d5426": {"t": 1785756771.0727205, "n": 13928, "h": "9c4e70d4f79cc022", "c": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing System.Threading.Tasks;\nusing CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\nusing Microsoft.UI.Text;\nusing Microsoft.UI.Xaml;\nusing Microsoft.UI.Xaml.Controls;\nusing Microsoft.UI.Xaml.Input;\nusing Microsoft.UI.Xaml.Media;\n\nnamespace CodexLocalRetrieval_Native;\n\n// The co-pilot: a multi-turn chat over the archive backed by DeepSeek (key autograbbed from the\n// environment) and the narrow ArchiveToolService. Mutations route through a confirm dialog; show_chat\n// opens a floating preview window. Tool results are data — the system prompt forbids obeying them.\npublic sealed partial class MainPage\n{\n private readonly List _copilotApi = new();\n private readonly List _previewWindows = new();\n private bool _copilotBusy;\n private string _copilotStatus = \"\";\n\n private static readonly string[] CopilotSuggestions =\n {\n \"Summarize my most recent chat\",\n \"Find chats about VENPOD and list them\",\n \"Open my most recent chat in a preview window\",\n \"Make a restore packet for my latest chat\"\n };\n\n private void ResetCopilot()\n {\n _copilotApi.Clear();\n _copilotApi.Add(ChatMessage.System(ArchiveToolService.SystemPrompt));\n _copilotStatus = \"\";\n }\n\n private IChatBackend? BuildCopilotBackend()\n {\n var provider = _archive.ActiveAiProvider()\n ?? _archive.EnsureAiProvider(\"DeepSeek\", \"https://api.deepseek.com\", \"deepseek-v4-flash\");\n var key = LoadApiKey(provider.Id);\n if (string.IsNullOrWhiteSpace(key)) return null;\n return new DeepSeekBackend(provider.BaseUrl, provider.Model, key);\n }\n\n private void RenderCopilot()\n {\n ScreenLabel.Text = \"Co-pilot\";\n TitleText.Text = \"Chat about your archive\";\n MainContent.Children.Clear();\n if (_copilotApi.Count == 0) ResetCopilot();\n\n MainContent.Children.Add(CopilotComposer());\n\n if (_copilotBusy) MainContent.Children.Add(CopilotNote(\"Thinking...\"));\n if (!string.IsNullOrWhiteSpace(_copilotStatus)) MainContent.Children.Add(CopilotNote(_copilotStatus));\n\n // Newest exchange first, directly under the composer, so the latest reply is visible\n // without scrolling. System + tool messages are not shown; tool calls render as chips.\n var visible = _copilotApi.Where(m => m.Role is \"user\" or \"assistant\").ToList();\n for (var i = visible.Count - 1; i >= 0; i--)\n {\n var m = visible[i];\n if (m.Role == \"user\")\n {\n MainContent.Children.Add(CopilotBubble(\"You\", m.Content ?? \"\", true));\n }\n else\n {\n if (m.ToolCalls is { Count: > 0 })\n foreach (var call in m.ToolCalls) MainContent.Children.Add(ToolChip(call.Function.Name, call.Function.Arguments));\n if (!string.IsNullOrWhiteSpace(m.Content))\n MainContent.Children.Add(CopilotBubble(\"Co-pilot\", m.Content!, false));\n }\n }\n\n if (visible.Count == 0) MainContent.Children.Add(CopilotSuggestionsCard());\n }\n\n private UIElement CopilotComposer()\n {\n var input = new TextBox\n {\n PlaceholderText = \"Ask about your chats, or tell me to organize them...\",\n AcceptsReturn = true,\n TextWrapping = TextWrapping.Wrap,\n MinHeight = 64,\n MaxHeight = 160,\n CornerRadius = ControlCornerRadius(),\n IsEnabled = !_copilotBusy\n };\n input.KeyDown += async (_, e) =>\n {\n if (e.Key == Windows.System.VirtualKey.Enter &&\n (Microsoft.UI.Input.InputKeyboardSource.GetKeyStateForCurrentThread(Windows.System.VirtualKey.Shift) & Windows.UI.Core.CoreVirtualKeyStates.Down) == 0)\n {\n e.Handled = true;\n var text = input.Text;\n input.Text = \"\";\n await SendCopilotAsync(text);\n }\n };\n\n var send = new Button { Style = (Style)Resources[\"PrimaryPillButtonStyle\"], Content = \"Send\", IsEnabled = !_copilotBusy };\n send.Click += async (_, _) => { var t = input.Text; input.Text = \"\"; await SendCopilotAsync(t); };\n\n var reset = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = \"New chat\" };\n reset.Click += (_, _) => { ResetCopilot(); RenderCopilot(); };\n\n var keyNote = new TextBlock\n {\n Text = \"DeepSeek key: \" + ApiKeySource(\"deepseek\"),\n Foreground = MutedBrush(),\n FontSize = 11,\n VerticalAlignment = VerticalAlignment.Center\n };\n\n var buttons = new Grid { Margin = new Thickness(0, 10, 0, 0), ColumnDefinitions = { new ColumnDefinition(), new ColumnDefinition { Width = GridLength.Auto } } };\n buttons.Children.Add(keyNote);\n var right = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };\n right.Children.Add(reset);\n right.Children.Add(send);\n Grid.SetColumn(right, 1);\n buttons.Children.Add(right);\n\n return Card(new StackPanel { Spacing = 0, Children = { input, buttons } });\n }\n\n private UIElement CopilotSuggestionsCard()\n {\n var stack = new StackPanel { Spacing = 8 };\n stack.Children.Add(new TextBlock { Text = \"Try asking\", Foreground = StrongBrush(), FontSize = 15, FontWeight = FontWeights.SemiBold });\n foreach (var s in CopilotSuggestions)\n {\n var b = new Button { Style = (Style)Resources[\"PillButtonStyle\"], Content = s, HorizontalAlignment = HorizontalAlignment.Left };\n b.Click += async (_, _) => await SendCopilotAsync(s);\n stack.Children.Add(b);\n }\n return Card(stack);\n }\n\n private async Task SendCopilotAsync(string text)\n {\n if (_copilotBusy || string.IsNullOrWhiteSpace(text)) return;\n if (_copilotApi.Count == 0) ResetCopilot();\n\n var backend = BuildCopilotBackend();\n if (backend is null)\n {\n _copilotStatus = \"No DeepSeek key found. Set DEEPSEEK_API_KEY in your environment (or add a provider in Settings), then try again.\";\n RenderCopilot();\n return;\n }\n\n _copilotApi.Add(ChatMessage.User(text.Trim()));\n _copilotBusy = true;\n _copilotStatus = \"\";\n RenderCopilot();\n\n try\n {\n var tools = new ArchiveToolService(_archive, showChat: OpenChatPreview).Tools();\n var orchestrator = new ChatOrchestrator(backend, tools, confirm: ConfirmCopilotActionAsync);\n var result = await orchestrator.RunAsync(_copilotApi);\n if (string.IsNullOrWhiteSpace(result.Answer) && !string.IsNullOrWhiteSpace(result.Error))\n _copilotStatus = result.Error!;\n }\n catch (Exception ex)\n {\n Diag.Log(\"Copilot error \" + ex);\n _copilotStatus = \"Error talking to the model: \" + ex.Message;\n }\n finally\n {\n _copilotBusy = false;\n RenderCopilot();\n }\n }\n\n private async Task ConfirmCopilotActionAsync(ChatTool tool, JsonElement args)\n {\n var dialog = new ContentDialog\n {\n Title = \"Allow this action?\",\n Content = $\"The co-pilot wants to run \\\"{tool.Name}\\\":\\n\\n{args}\\n\\nThis changes app metadata only — your chat files are never modified.\",\n PrimaryButtonText = \"Allow\",\n CloseButtonText = \"Skip\",\n DefaultButton = ContentDialogButton.Primary,\n XamlRoot = XamlRoot\n };\n return await dialog.ShowAsync() == ContentDialogResult.Primary;\n }\n\n private void OpenChatPreview(string id)\n {\n var session = _archive.GetSession(id);\n if (session is null) return;\n var snapshot = session.Messages\n .Take(80)\n .Select(m => (m.RoleLabel, ArchiveService.ForReading(m.Text)))\n .ToList();\n var title = session.DisplayTitle;\n var subtitle = $\"{(string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase) ? \"Claude\" : \"Codex\")} · {session.WorkspaceName}\";\n DispatcherQueue.TryEnqueue(() =>\n {\n try\n {\n var window = new ChatPreviewWindow(title, subtitle, snapshot);\n _previewWindows.Add(window);\n window.Closed += (_, _) => _previewWindows.Remove(window);\n window.Activate();\n Diag.Log($\"Preview window opened for \\\"{title}\\\" ({_previewWindows.Count} open)\");\n }\n catch (Exception ex) { Diag.Log(\"Preview window failed \" + ex); }\n });\n }\n\n private UIElement CopilotBubble(string who, string text, bool isUser)\n {\n var stack = new StackPanel { Spacing = 6 };\n stack.Children.Add(new TextBlock { Text = who, Foreground = MutedBrush(), FontSize = 12 });\n if (isUser)\n stack.Children.Add(new TextBlock { Text = text, Foreground = StrongBrush(), TextWrapping = TextWrapping.Wrap, LineHeight = 22, FontSize = 14 });\n else\n stack.Children.Add(BuildMarkdown(text)); // assistant replies are markdown\n return new Border\n {\n Background = isUser ? AccentVerySoftBrush() : PanelBrush(),\n BorderBrush = LineBrush(),\n BorderThickness = new Thickness(1),\n CornerRadius = PanelCornerRadius(),\n Padding = new Thickness(16),\n HorizontalAlignment = HorizontalAlignment.Stretch,\n Child = stack\n };\n }\n\n private UIElement ToolChip(string name, string argsJson)\n {\n var text = string.IsNullOrWhiteSpace(argsJson) || argsJson == \"{}\" ? name : $\"{name} {Trim(argsJson, 80)}\";\n return new Border\n {\n Background = new SolidColorBrush(Windows.UI.Color.FromArgb(36, 120, 170, 255)),\n CornerRadius = new CornerRadius(8),\n Padding = new Thickness(10, 4, 10, 4),\n HorizontalAlignment = HorizontalAlignment.Left,\n Child = new StackPanel\n {\n Orientation = Orientation.Horizontal,\n Spacing = 8,\n Children =\n {\n new FontIcon { FontFamily = new FontFamily(\"Segoe Fluent Icons\"), FontSize = 12, Glyph = \"\", Foreground = MutedBrush() },\n new TextBlock { Text = text, Foreground = MutedBrush(), FontSize = 12, VerticalAlignment = VerticalAlignment.Center }\n }\n }\n };\n }\n\n private UIElement CopilotNote(string text) =>\n new TextBlock { Text = text, Foreground = MutedBrush(), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(2, 0, 0, 0) };\n\n // Lightweight markdown -> RichTextBlock: headers, **bold**, `code`, bullets, and table rows\n // (kept monospaced). Robust to unclosed markers; not a full parser, just readable formatting.\n private RichTextBlock BuildMarkdown(string text)\n {\n var rtb = new RichTextBlock { TextWrapping = TextWrapping.Wrap, Foreground = StrongBrush(), LineHeight = 22, FontSize = 14 };\n foreach (var raw in (text ?? \"\").Replace(\"\\r\", \"\").Split('\\n'))\n {\n var line = raw;\n var para = new Microsoft.UI.Xaml.Documents.Paragraph();\n\n var t = line.Trim();\n if (t is \"---\" or \"***\" or \"___\")\n {\n para.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = \"──────────\", Foreground = MutedBrush() });\n rtb.Blocks.Add(para);\n continue;\n }\n\n var hashes = 0;\n while (hashes < line.Length && line[hashes] == '#') hashes++;\n if (hashes > 0 && hashes < line.Length && line[hashes] == ' ')\n {\n line = line[(hashes + 1)..];\n para.FontWeight = FontWeights.SemiBold;\n para.FontSize = hashes <= 2 ? 16 : 15;\n para.Margin = new Thickness(0, 8, 0, 2);\n }\n\n var trimmedStart = line.TrimStart();\n if (trimmedStart.StartsWith(\"- \") || trimmedStart.StartsWith(\"* \"))\n line = \" • \" + trimmedStart[2..];\n\n var monospace = line.Contains('|'); // table row\n AddInlineRuns(para.Inlines, line, monospace);\n rtb.Blocks.Add(para);\n }\n return rtb;\n }\n\n private void AddInlineRuns(Microsoft.UI.Xaml.Documents.InlineCollection inlines, string line, bool monospace)\n {\n var i = 0;\n while (i < line.Length)\n {\n if (line[i] == '*' && i + 1 < line.Length && line[i + 1] == '*')\n {\n var end = line.IndexOf(\"**\", i + 2, StringComparison.Ordinal);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 2)..end], FontWeight = FontWeights.Bold }); i = end + 2; continue; }\n }\n if (line[i] == '`')\n {\n var end = line.IndexOf('`', i + 1);\n if (end > 0) { inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = line[(i + 1)..end], FontFamily = new FontFamily(\"Consolas\") }); i = end + 1; continue; }\n }\n var next = NextMarker(line, i);\n var run = new Microsoft.UI.Xaml.Documents.Run { Text = line[i..next] };\n if (monospace) run.FontFamily = new FontFamily(\"Consolas\");\n inlines.Add(run);\n i = next;\n }\n }\n\n private static int NextMarker(string s, int from)\n {\n for (var k = from + 1; k < s.Length; k++)\n {\n if (s[k] == '`') return k;\n if (s[k] == '*' && k + 1 < s.Length && s[k + 1] == '*') return k;\n }\n return s.Length;\n }\n}\n"}}, "native/CodexLocalRetrieval.Core/Remote/RemoteApi.cs": {"0bd60df9ea622f66f6e6720e6824fe5916840a06": {"t": 1785745824.9104557, "n": 7984, "h": "7d73d0d9e35e040b", "c": "using CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\n\nnamespace CodexLocalRetrieval.Core.Remote;\n\n// HTTP-agnostic request bodies (minimal API binds JSON onto these).\npublic sealed record CopilotTurn(string? Role, string? Content);\npublic sealed record CopilotRequest(string Message, List? History);\npublic sealed record ResumeRequest(bool Launch);\npublic sealed record FavoriteRequest(bool Favorite);\n\n// The remote API surface. Every method is plain (no HttpContext) so it can be unit-tested directly\n// against a real ArchiveService. It reuses the SAME co-pilot pieces as the desktop app. Read-only by\n// default; the only state-changing operations are explicit, user-initiated taps (favorite / resume),\n// which ARE the approval. Chat content sent to the model is redacted by ArchiveToolService; direct\n// reads optionally redact when RedactReads is set (see SecretRedactor).\npublic sealed class RemoteApi\n{\n private readonly ArchiveService _archive;\n private readonly Func _backendFactory;\n private readonly bool _redactReads;\n private readonly bool _allowLaunch;\n\n public RemoteApi(ArchiveService archive, Func backendFactory, bool redactReads = false, bool allowLaunch = false)\n {\n _archive = archive;\n _backendFactory = backendFactory;\n _redactReads = redactReads;\n _allowLaunch = allowLaunch;\n }\n\n public object Stats()\n {\n var sessions = _archive.Store.Sessions.Values.Where(s => !s.Archived).ToList();\n var byTool = sessions.GroupBy(s => string.IsNullOrWhiteSpace(s.Tool) ? \"codex\" : s.Tool.ToLowerInvariant())\n .ToDictionary(g => g.Key, g => g.Count());\n return new\n {\n totalChats = sessions.Count,\n byTool,\n favorites = sessions.Count(s => s.Pinned),\n projects = _archive.Store.Collections.Count\n };\n }\n\n public object Search(string? q, int limit)\n {\n limit = Math.Clamp(limit, 1, 50);\n var results = _archive.DeepSearch(q ?? \"\", limit).Select(h => new\n {\n id = h.Session.Id,\n title = SecretRedactor.Scrub(h.Session.DisplayTitle),\n workspace = SecretRedactor.Scrub(h.Session.WorkspaceName),\n tool = h.Session.Tool,\n pinned = h.Session.Pinned,\n updatedAt = h.Session.UpdatedAt,\n snippet = SecretRedactor.Scrub(Cap(h.Snippet, 240))\n }).ToList();\n return new { query = q ?? \"\", count = results.Count, results };\n }\n\n public object? Read(string id, int page, int pageSize)\n {\n var session = _archive.GetSession(id);\n if (session is null) return null;\n _archive.EnsureContent(session); // content lazy-loads from the source file\n page = Math.Max(0, page);\n pageSize = Math.Clamp(pageSize, 1, 50);\n var all = session.Messages;\n var slice = all.Skip(page * pageSize).Take(pageSize).Select(m => new\n {\n role = m.RoleLabel,\n text = Reveal(ArchiveService.ForReading(m.Text))\n }).ToList();\n return new\n {\n id = session.Id,\n title = SecretRedactor.Scrub(session.DisplayTitle),\n tool = session.Tool,\n workspace = SecretRedactor.Scrub(session.WorkspaceName),\n updatedAt = session.UpdatedAt,\n pinned = session.Pinned,\n totalMessages = all.Count,\n page,\n pageSize,\n hasMore = (page + 1) * pageSize < all.Count,\n messages = slice\n };\n }\n\n public object? Events(string id, int limit)\n {\n var session = _archive.GetSession(id);\n if (session is null) return null;\n limit = Math.Clamp(limit, 1, 1000);\n var events = _archive.ReadEvents(session, limit).Select(e => new\n {\n kind = e.Kind,\n timestamp = e.Timestamp,\n preview = Reveal(e.Preview)\n }).ToList();\n return new { id = session.Id, title = SecretRedactor.Scrub(session.DisplayTitle), count = events.Count, events };\n }\n\n public async Task CopilotAsync(string message, List? history, CancellationToken ct = default)\n {\n if (string.IsNullOrWhiteSpace(message)) return new { error = \"Empty message.\" };\n var backend = _backendFactory();\n if (backend is null) return new { error = \"No model key configured. Set DEEPSEEK_API_KEY on the server.\" };\n\n // Read-only tool surface for the remote co-pilot: no callbacks (so show_chat/resume_chat are\n // not registered) and mutation tools filtered out. The user does state changes via explicit taps.\n var tools = new ArchiveToolService(_archive).Tools().Where(t => !t.IsMutation).ToList();\n var orchestrator = new ChatOrchestrator(backend, tools);\n\n var messages = new List { ChatMessage.System(ArchiveToolService.SystemPrompt) };\n if (history is not null)\n foreach (var turn in history.TakeLast(24))\n {\n var role = turn.Role?.ToLowerInvariant();\n if ((role == \"user\" || role == \"assistant\") && !string.IsNullOrWhiteSpace(turn.Content))\n messages.Add(new ChatMessage { Role = role, Content = turn.Content });\n }\n messages.Add(ChatMessage.User(message.Trim()));\n\n var result = await orchestrator.RunAsync(messages, ct);\n return new\n {\n answer = result.Answer,\n error = result.Error,\n stopped = result.Stopped,\n activity = result.Activity.Select(a => new { tool = a.Tool, ok = a.Ok, summary = a.ResultSummary }).ToList()\n };\n }\n\n // Build the resume command for a chat (\"send the next task\"). Only actually launches a terminal\n // when the server is configured to allow it AND the trusted-exe check passes; otherwise returns\n // the command for the user to run. Refusals from BuildResumeLaunch surface as launched=false.\n public object ResumeCommand(string id, bool launch)\n {\n var session = _archive.GetSession(id);\n if (session is null) return new { error = \"No chat with that id.\" };\n var resume = _archive.BuildResumeLaunch(session);\n var refused = string.IsNullOrEmpty(resume.Exe);\n var launched = false;\n string? note = refused ? resume.DisplayCommand : null;\n\n if (!refused && launch && _allowLaunch)\n {\n try\n {\n var psi = new System.Diagnostics.ProcessStartInfo\n {\n FileName = resume.Exe,\n Arguments = resume.Arguments,\n WorkingDirectory = resume.WorkingDirectory,\n UseShellExecute = true // open in its own terminal window on the host\n };\n System.Diagnostics.Process.Start(psi);\n launched = true;\n }\n catch (Exception ex) { note = \"Launch failed: \" + ex.Message; }\n }\n else if (!refused && launch && !_allowLaunch)\n {\n note = \"Launch is disabled on this server (set CLR_REMOTE_ALLOW_LAUNCH=1). Copy the command instead.\";\n }\n\n return new\n {\n id = session.Id,\n tool = session.Tool,\n command = resume.DisplayCommand,\n workingDirectory = resume.WorkingDirectory,\n launched,\n note\n };\n }\n\n public async Task FavoriteAsync(string id, bool favorite)\n {\n var ok = await _archive.SetFavoriteAsync(id, favorite);\n return new { ok, id, favorite, message = ok ? (favorite ? \"Pinned.\" : \"Unpinned.\") : \"No chat with that id.\" };\n }\n\n private string Reveal(string s) => _redactReads ? SecretRedactor.Scrub(s) : s;\n\n private static string Cap(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"...\";\n}\n"}, "eae5e0cf9f6b3676162ea2101a70f7529502bc97": {"t": 1785756762.5391927, "n": 7984, "h": "7d73d0d9e35e040b", "c": "using CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\n\nnamespace CodexLocalRetrieval.Core.Remote;\n\n// HTTP-agnostic request bodies (minimal API binds JSON onto these).\npublic sealed record CopilotTurn(string? Role, string? Content);\npublic sealed record CopilotRequest(string Message, List? History);\npublic sealed record ResumeRequest(bool Launch);\npublic sealed record FavoriteRequest(bool Favorite);\n\n// The remote API surface. Every method is plain (no HttpContext) so it can be unit-tested directly\n// against a real ArchiveService. It reuses the SAME co-pilot pieces as the desktop app. Read-only by\n// default; the only state-changing operations are explicit, user-initiated taps (favorite / resume),\n// which ARE the approval. Chat content sent to the model is redacted by ArchiveToolService; direct\n// reads optionally redact when RedactReads is set (see SecretRedactor).\npublic sealed class RemoteApi\n{\n private readonly ArchiveService _archive;\n private readonly Func _backendFactory;\n private readonly bool _redactReads;\n private readonly bool _allowLaunch;\n\n public RemoteApi(ArchiveService archive, Func backendFactory, bool redactReads = false, bool allowLaunch = false)\n {\n _archive = archive;\n _backendFactory = backendFactory;\n _redactReads = redactReads;\n _allowLaunch = allowLaunch;\n }\n\n public object Stats()\n {\n var sessions = _archive.Store.Sessions.Values.Where(s => !s.Archived).ToList();\n var byTool = sessions.GroupBy(s => string.IsNullOrWhiteSpace(s.Tool) ? \"codex\" : s.Tool.ToLowerInvariant())\n .ToDictionary(g => g.Key, g => g.Count());\n return new\n {\n totalChats = sessions.Count,\n byTool,\n favorites = sessions.Count(s => s.Pinned),\n projects = _archive.Store.Collections.Count\n };\n }\n\n public object Search(string? q, int limit)\n {\n limit = Math.Clamp(limit, 1, 50);\n var results = _archive.DeepSearch(q ?? \"\", limit).Select(h => new\n {\n id = h.Session.Id,\n title = SecretRedactor.Scrub(h.Session.DisplayTitle),\n workspace = SecretRedactor.Scrub(h.Session.WorkspaceName),\n tool = h.Session.Tool,\n pinned = h.Session.Pinned,\n updatedAt = h.Session.UpdatedAt,\n snippet = SecretRedactor.Scrub(Cap(h.Snippet, 240))\n }).ToList();\n return new { query = q ?? \"\", count = results.Count, results };\n }\n\n public object? Read(string id, int page, int pageSize)\n {\n var session = _archive.GetSession(id);\n if (session is null) return null;\n _archive.EnsureContent(session); // content lazy-loads from the source file\n page = Math.Max(0, page);\n pageSize = Math.Clamp(pageSize, 1, 50);\n var all = session.Messages;\n var slice = all.Skip(page * pageSize).Take(pageSize).Select(m => new\n {\n role = m.RoleLabel,\n text = Reveal(ArchiveService.ForReading(m.Text))\n }).ToList();\n return new\n {\n id = session.Id,\n title = SecretRedactor.Scrub(session.DisplayTitle),\n tool = session.Tool,\n workspace = SecretRedactor.Scrub(session.WorkspaceName),\n updatedAt = session.UpdatedAt,\n pinned = session.Pinned,\n totalMessages = all.Count,\n page,\n pageSize,\n hasMore = (page + 1) * pageSize < all.Count,\n messages = slice\n };\n }\n\n public object? Events(string id, int limit)\n {\n var session = _archive.GetSession(id);\n if (session is null) return null;\n limit = Math.Clamp(limit, 1, 1000);\n var events = _archive.ReadEvents(session, limit).Select(e => new\n {\n kind = e.Kind,\n timestamp = e.Timestamp,\n preview = Reveal(e.Preview)\n }).ToList();\n return new { id = session.Id, title = SecretRedactor.Scrub(session.DisplayTitle), count = events.Count, events };\n }\n\n public async Task CopilotAsync(string message, List? history, CancellationToken ct = default)\n {\n if (string.IsNullOrWhiteSpace(message)) return new { error = \"Empty message.\" };\n var backend = _backendFactory();\n if (backend is null) return new { error = \"No model key configured. Set DEEPSEEK_API_KEY on the server.\" };\n\n // Read-only tool surface for the remote co-pilot: no callbacks (so show_chat/resume_chat are\n // not registered) and mutation tools filtered out. The user does state changes via explicit taps.\n var tools = new ArchiveToolService(_archive).Tools().Where(t => !t.IsMutation).ToList();\n var orchestrator = new ChatOrchestrator(backend, tools);\n\n var messages = new List { ChatMessage.System(ArchiveToolService.SystemPrompt) };\n if (history is not null)\n foreach (var turn in history.TakeLast(24))\n {\n var role = turn.Role?.ToLowerInvariant();\n if ((role == \"user\" || role == \"assistant\") && !string.IsNullOrWhiteSpace(turn.Content))\n messages.Add(new ChatMessage { Role = role, Content = turn.Content });\n }\n messages.Add(ChatMessage.User(message.Trim()));\n\n var result = await orchestrator.RunAsync(messages, ct);\n return new\n {\n answer = result.Answer,\n error = result.Error,\n stopped = result.Stopped,\n activity = result.Activity.Select(a => new { tool = a.Tool, ok = a.Ok, summary = a.ResultSummary }).ToList()\n };\n }\n\n // Build the resume command for a chat (\"send the next task\"). Only actually launches a terminal\n // when the server is configured to allow it AND the trusted-exe check passes; otherwise returns\n // the command for the user to run. Refusals from BuildResumeLaunch surface as launched=false.\n public object ResumeCommand(string id, bool launch)\n {\n var session = _archive.GetSession(id);\n if (session is null) return new { error = \"No chat with that id.\" };\n var resume = _archive.BuildResumeLaunch(session);\n var refused = string.IsNullOrEmpty(resume.Exe);\n var launched = false;\n string? note = refused ? resume.DisplayCommand : null;\n\n if (!refused && launch && _allowLaunch)\n {\n try\n {\n var psi = new System.Diagnostics.ProcessStartInfo\n {\n FileName = resume.Exe,\n Arguments = resume.Arguments,\n WorkingDirectory = resume.WorkingDirectory,\n UseShellExecute = true // open in its own terminal window on the host\n };\n System.Diagnostics.Process.Start(psi);\n launched = true;\n }\n catch (Exception ex) { note = \"Launch failed: \" + ex.Message; }\n }\n else if (!refused && launch && !_allowLaunch)\n {\n note = \"Launch is disabled on this server (set CLR_REMOTE_ALLOW_LAUNCH=1). Copy the command instead.\";\n }\n\n return new\n {\n id = session.Id,\n tool = session.Tool,\n command = resume.DisplayCommand,\n workingDirectory = resume.WorkingDirectory,\n launched,\n note\n };\n }\n\n public async Task FavoriteAsync(string id, bool favorite)\n {\n var ok = await _archive.SetFavoriteAsync(id, favorite);\n return new { ok, id, favorite, message = ok ? (favorite ? \"Pinned.\" : \"Unpinned.\") : \"No chat with that id.\" };\n }\n\n private string Reveal(string s) => _redactReads ? SecretRedactor.Scrub(s) : s;\n\n private static string Cap(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"...\";\n}\n"}, "f156a67bb13a5c307e88c7c1a8a2a75cf7d8111b": {"t": 1785756764.1082442, "n": 7900, "h": "664c6c197d8a19fe", "c": "using CodexLocalRetrieval.Core.Chat;\nusing CodexLocalRetrieval.Core.Services;\n\nnamespace CodexLocalRetrieval.Core.Remote;\n\n// HTTP-agnostic request bodies (minimal API binds JSON onto these).\npublic sealed record CopilotTurn(string? Role, string? Content);\npublic sealed record CopilotRequest(string Message, List? History);\npublic sealed record ResumeRequest(bool Launch);\npublic sealed record FavoriteRequest(bool Favorite);\n\n// The remote API surface. Every method is plain (no HttpContext) so it can be unit-tested directly\n// against a real ArchiveService. It reuses the SAME co-pilot pieces as the desktop app. Read-only by\n// default; the only state-changing operations are explicit, user-initiated taps (favorite / resume),\n// which ARE the approval. Chat content sent to the model is redacted by ArchiveToolService; direct\n// reads optionally redact when RedactReads is set (see SecretRedactor).\npublic sealed class RemoteApi\n{\n private readonly ArchiveService _archive;\n private readonly Func _backendFactory;\n private readonly bool _redactReads;\n private readonly bool _allowLaunch;\n\n public RemoteApi(ArchiveService archive, Func backendFactory, bool redactReads = false, bool allowLaunch = false)\n {\n _archive = archive;\n _backendFactory = backendFactory;\n _redactReads = redactReads;\n _allowLaunch = allowLaunch;\n }\n\n public object Stats()\n {\n var sessions = _archive.Store.Sessions.Values.Where(s => !s.Archived).ToList();\n var byTool = sessions.GroupBy(s => string.IsNullOrWhiteSpace(s.Tool) ? \"codex\" : s.Tool.ToLowerInvariant())\n .ToDictionary(g => g.Key, g => g.Count());\n return new\n {\n totalChats = sessions.Count,\n byTool,\n favorites = sessions.Count(s => s.Pinned),\n projects = _archive.Store.Collections.Count\n };\n }\n\n public object Search(string? q, int limit)\n {\n limit = Math.Clamp(limit, 1, 50);\n var results = _archive.DeepSearch(q ?? \"\", limit).Select(h => new\n {\n id = h.Session.Id,\n title = SecretRedactor.Scrub(h.Session.DisplayTitle),\n workspace = SecretRedactor.Scrub(h.Session.WorkspaceName),\n tool = h.Session.Tool,\n pinned = h.Session.Pinned,\n updatedAt = h.Session.UpdatedAt,\n snippet = SecretRedactor.Scrub(Cap(h.Snippet, 240))\n }).ToList();\n return new { query = q ?? \"\", count = results.Count, results };\n }\n\n public object? Read(string id, int page, int pageSize)\n {\n var session = _archive.GetSession(id);\n if (session is null) return null;\n page = Math.Max(0, page);\n pageSize = Math.Clamp(pageSize, 1, 50);\n var all = session.Messages;\n var slice = all.Skip(page * pageSize).Take(pageSize).Select(m => new\n {\n role = m.RoleLabel,\n text = Reveal(ArchiveService.ForReading(m.Text))\n }).ToList();\n return new\n {\n id = session.Id,\n title = SecretRedactor.Scrub(session.DisplayTitle),\n tool = session.Tool,\n workspace = SecretRedactor.Scrub(session.WorkspaceName),\n updatedAt = session.UpdatedAt,\n pinned = session.Pinned,\n totalMessages = all.Count,\n page,\n pageSize,\n hasMore = (page + 1) * pageSize < all.Count,\n messages = slice\n };\n }\n\n public object? Events(string id, int limit)\n {\n var session = _archive.GetSession(id);\n if (session is null) return null;\n limit = Math.Clamp(limit, 1, 1000);\n var events = _archive.ReadEvents(session, limit).Select(e => new\n {\n kind = e.Kind,\n timestamp = e.Timestamp,\n preview = Reveal(e.Preview)\n }).ToList();\n return new { id = session.Id, title = SecretRedactor.Scrub(session.DisplayTitle), count = events.Count, events };\n }\n\n public async Task CopilotAsync(string message, List? history, CancellationToken ct = default)\n {\n if (string.IsNullOrWhiteSpace(message)) return new { error = \"Empty message.\" };\n var backend = _backendFactory();\n if (backend is null) return new { error = \"No model key configured. Set DEEPSEEK_API_KEY on the server.\" };\n\n // Read-only tool surface for the remote co-pilot: no callbacks (so show_chat/resume_chat are\n // not registered) and mutation tools filtered out. The user does state changes via explicit taps.\n var tools = new ArchiveToolService(_archive).Tools().Where(t => !t.IsMutation).ToList();\n var orchestrator = new ChatOrchestrator(backend, tools);\n\n var messages = new List { ChatMessage.System(ArchiveToolService.SystemPrompt) };\n if (history is not null)\n foreach (var turn in history.TakeLast(24))\n {\n var role = turn.Role?.ToLowerInvariant();\n if ((role == \"user\" || role == \"assistant\") && !string.IsNullOrWhiteSpace(turn.Content))\n messages.Add(new ChatMessage { Role = role, Content = turn.Content });\n }\n messages.Add(ChatMessage.User(message.Trim()));\n\n var result = await orchestrator.RunAsync(messages, ct);\n return new\n {\n answer = result.Answer,\n error = result.Error,\n stopped = result.Stopped,\n activity = result.Activity.Select(a => new { tool = a.Tool, ok = a.Ok, summary = a.ResultSummary }).ToList()\n };\n }\n\n // Build the resume command for a chat (\"send the next task\"). Only actually launches a terminal\n // when the server is configured to allow it AND the trusted-exe check passes; otherwise returns\n // the command for the user to run. Refusals from BuildResumeLaunch surface as launched=false.\n public object ResumeCommand(string id, bool launch)\n {\n var session = _archive.GetSession(id);\n if (session is null) return new { error = \"No chat with that id.\" };\n var resume = _archive.BuildResumeLaunch(session);\n var refused = string.IsNullOrEmpty(resume.Exe);\n var launched = false;\n string? note = refused ? resume.DisplayCommand : null;\n\n if (!refused && launch && _allowLaunch)\n {\n try\n {\n var psi = new System.Diagnostics.ProcessStartInfo\n {\n FileName = resume.Exe,\n Arguments = resume.Arguments,\n WorkingDirectory = resume.WorkingDirectory,\n UseShellExecute = true // open in its own terminal window on the host\n };\n System.Diagnostics.Process.Start(psi);\n launched = true;\n }\n catch (Exception ex) { note = \"Launch failed: \" + ex.Message; }\n }\n else if (!refused && launch && !_allowLaunch)\n {\n note = \"Launch is disabled on this server (set CLR_REMOTE_ALLOW_LAUNCH=1). Copy the command instead.\";\n }\n\n return new\n {\n id = session.Id,\n tool = session.Tool,\n command = resume.DisplayCommand,\n workingDirectory = resume.WorkingDirectory,\n launched,\n note\n };\n }\n\n public async Task FavoriteAsync(string id, bool favorite)\n {\n var ok = await _archive.SetFavoriteAsync(id, favorite);\n return new { ok, id, favorite, message = ok ? (favorite ? \"Pinned.\" : \"Unpinned.\") : \"No chat with that id.\" };\n }\n\n private string Reveal(string s) => _redactReads ? SecretRedactor.Scrub(s) : s;\n\n private static string Cap(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"...\";\n}\n"}}, "native/CodexLocalRetrieval.Core/Chat/DeepSeekBackend.cs": {"0bd60df9ea622f66f6e6720e6824fe5916840a06": {"t": 1785745825.1129029, "n": 8846, "h": "c63a694b18db49a0", "c": "using System.Net.Http.Headers;\nusing System.Text;\nusing System.Text.Json;\n\nnamespace CodexLocalRetrieval.Core.Chat;\n\n// OpenAI-compatible tool-calling backend (DeepSeek primary; also fits OpenAI). Non-streaming,\n// tool_choice=auto when tools are present (per DeepSeek's current tool-calls API). Owns only the HTTP\n// round-trip; the ChatOrchestrator owns the loop. Arguments produced by the model are NOT trusted\n// here — the orchestrator/tools validate them.\npublic sealed class DeepSeekBackend : IChatBackend\n{\n private static readonly JsonSerializerOptions Wire = new()\n {\n DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n };\n\n private readonly HttpClient _http;\n private readonly string _baseUrl;\n private readonly string _model;\n private readonly string _apiKey;\n private readonly int _maxTokens;\n private readonly int _maxAttempts;\n private readonly Func _delay;\n\n public DeepSeekBackend(string baseUrl, string model, string apiKey, HttpClient? http = null, int maxTokens = 2000,\n int maxAttempts = 3, Func? delay = null)\n {\n _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(90) };\n _baseUrl = baseUrl;\n _model = model;\n _apiKey = apiKey;\n _maxTokens = maxTokens;\n _maxAttempts = Math.Max(1, maxAttempts);\n _delay = delay ?? ((d, ct) => Task.Delay(d, ct)); // injectable so tests don't actually sleep\n }\n\n public string Name => \"deepseek\";\n public bool SupportsTools => true;\n\n public async Task CompleteAsync(IReadOnlyList messages, IReadOnlyList tools, CancellationToken cancellationToken)\n {\n if (string.IsNullOrWhiteSpace(_apiKey)) throw new InvalidOperationException(\"No API key. Set DEEPSEEK_API_KEY.\");\n\n var payload = new Dictionary\n {\n [\"model\"] = _model,\n [\"messages\"] = messages,\n [\"stream\"] = false,\n [\"max_tokens\"] = _maxTokens\n };\n if (tools.Count > 0)\n {\n payload[\"tools\"] = tools.Select(t => new\n {\n type = \"function\",\n function = new { name = t.Name, description = t.Description, parameters = t.ParametersSchema }\n }).ToList();\n payload[\"tool_choice\"] = \"auto\";\n }\n\n // Serialize once; each attempt needs a fresh request/content (a sent one can't be reused).\n var json = JsonSerializer.Serialize(payload, Wire);\n InvalidOperationException? lastTransient = null;\n\n for (var attempt = 1; attempt <= _maxAttempts; attempt++)\n {\n using var request = new HttpRequestMessage(HttpMethod.Post, BuildEndpoint(_baseUrl));\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", _apiKey);\n request.Content = new StringContent(json, Encoding.UTF8, \"application/json\");\n\n HttpResponseMessage response;\n try\n {\n response = await _http.SendAsync(request, cancellationToken);\n }\n catch (HttpRequestException ex) // connection refused / DNS / TLS — transient, worth a retry\n {\n lastTransient = new InvalidOperationException(\"Network error talking to DeepSeek: \" + ex.Message, ex);\n if (attempt < _maxAttempts) { await Backoff(attempt, null, cancellationToken); continue; }\n throw lastTransient;\n }\n catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) // HttpClient timeout, not a user cancel\n {\n lastTransient = new InvalidOperationException(\"DeepSeek request timed out.\");\n if (attempt < _maxAttempts) { await Backoff(attempt, null, cancellationToken); continue; }\n throw lastTransient;\n }\n\n using (response)\n {\n var body = await response.Content.ReadAsStringAsync(cancellationToken);\n if (response.IsSuccessStatusCode) return Parse(body);\n\n // Retry rate-limit / transient server errors; fail fast on other 4xx (bad key, bad request).\n if (IsTransient(response.StatusCode) && attempt < _maxAttempts)\n {\n lastTransient = new InvalidOperationException($\"DeepSeek returned {(int)response.StatusCode}: {Trim(body)}\");\n await Backoff(attempt, response.Headers.RetryAfter, cancellationToken);\n continue;\n }\n throw new InvalidOperationException($\"DeepSeek returned {(int)response.StatusCode}: {Trim(body)}\");\n }\n }\n\n throw lastTransient ?? new InvalidOperationException(\"DeepSeek request failed.\");\n }\n\n private static bool IsTransient(System.Net.HttpStatusCode code) => (int)code switch\n {\n 408 or 429 or 500 or 502 or 503 or 504 => true,\n _ => false\n };\n\n // Exponential backoff, but honor a server Retry-After when present. Bounded so a hostile header\n // can't park the UI on \"Thinking...\" indefinitely.\n private async Task Backoff(int attempt, System.Net.Http.Headers.RetryConditionHeaderValue? retryAfter, CancellationToken ct)\n {\n TimeSpan delay;\n if (retryAfter?.Delta is TimeSpan d) delay = d;\n else if (retryAfter?.Date is DateTimeOffset when) delay = when - DateTimeOffset.UtcNow;\n else delay = TimeSpan.FromMilliseconds(400 * Math.Pow(2, attempt - 1));\n if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;\n if (delay > TimeSpan.FromSeconds(20)) delay = TimeSpan.FromSeconds(20);\n await _delay(delay, ct);\n }\n\n public static BackendReply Parse(string body) // public so it can be unit-tested directly\n {\n using var doc = JsonDocument.Parse(body);\n var root = doc.RootElement;\n\n // Some OpenAI-compatible providers return an error-shaped body with HTTP 200.\n if (root.TryGetProperty(\"error\", out var error))\n throw new InvalidOperationException(\"Provider error: \" +\n (error.TryGetProperty(\"message\", out var em) ? em.GetString() : error.ToString()));\n\n if (!root.TryGetProperty(\"choices\", out var choices) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0)\n throw new InvalidOperationException(\"Provider returned no choices.\");\n var choice = choices[0];\n if (!choice.TryGetProperty(\"message\", out var message))\n throw new InvalidOperationException(\"Provider returned no message.\");\n\n string? content = message.TryGetProperty(\"content\", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : null;\n\n List? toolCalls = null;\n if (message.TryGetProperty(\"tool_calls\", out var tc) && tc.ValueKind == JsonValueKind.Array && tc.GetArrayLength() > 0)\n {\n toolCalls = new List();\n foreach (var t in tc.EnumerateArray())\n {\n if (!t.TryGetProperty(\"function\", out var fn)) continue; // skip malformed tool calls\n toolCalls.Add(new ToolCall\n {\n Id = t.TryGetProperty(\"id\", out var id) ? id.GetString() ?? \"\" : \"\",\n Function = new ToolCallFunction\n {\n Name = fn.TryGetProperty(\"name\", out var n) ? n.GetString() ?? \"\" : \"\",\n Arguments = fn.TryGetProperty(\"arguments\", out var a) && a.ValueKind == JsonValueKind.String ? a.GetString() ?? \"{}\" : \"{}\"\n }\n });\n }\n if (toolCalls.Count == 0) toolCalls = null;\n }\n\n var finish = choice.TryGetProperty(\"finish_reason\", out var f) ? f.GetString() : null;\n // A truncated final answer (no tool calls) should not silently read as complete.\n if (finish == \"length\" && toolCalls is null && !string.IsNullOrEmpty(content))\n content += \"\\n\\n_[Response was cut off — ask me to continue.]_\";\n\n return new BackendReply\n {\n Message = new ChatMessage { Role = \"assistant\", Content = content, ToolCalls = toolCalls },\n FinishReason = finish\n };\n }\n\n private static Uri BuildEndpoint(string baseUrl)\n {\n var trimmed = baseUrl.Trim().TrimEnd('/');\n if (trimmed.EndsWith(\"/chat/completions\", StringComparison.OrdinalIgnoreCase)) return new Uri(trimmed);\n return new Uri($\"{trimmed}/chat/completions\");\n }\n\n private static string Trim(string s)\n {\n var clean = s.Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Trim();\n return clean[..Math.Min(400, clean.Length)];\n }\n}\n"}, "e82274ce6205e10ad64ee0c6f02a3f7c57f26ca3": {"t": 1785756762.6759, "n": 8846, "h": "c63a694b18db49a0", "c": "using System.Net.Http.Headers;\nusing System.Text;\nusing System.Text.Json;\n\nnamespace CodexLocalRetrieval.Core.Chat;\n\n// OpenAI-compatible tool-calling backend (DeepSeek primary; also fits OpenAI). Non-streaming,\n// tool_choice=auto when tools are present (per DeepSeek's current tool-calls API). Owns only the HTTP\n// round-trip; the ChatOrchestrator owns the loop. Arguments produced by the model are NOT trusted\n// here — the orchestrator/tools validate them.\npublic sealed class DeepSeekBackend : IChatBackend\n{\n private static readonly JsonSerializerOptions Wire = new()\n {\n DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n };\n\n private readonly HttpClient _http;\n private readonly string _baseUrl;\n private readonly string _model;\n private readonly string _apiKey;\n private readonly int _maxTokens;\n private readonly int _maxAttempts;\n private readonly Func _delay;\n\n public DeepSeekBackend(string baseUrl, string model, string apiKey, HttpClient? http = null, int maxTokens = 2000,\n int maxAttempts = 3, Func? delay = null)\n {\n _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(90) };\n _baseUrl = baseUrl;\n _model = model;\n _apiKey = apiKey;\n _maxTokens = maxTokens;\n _maxAttempts = Math.Max(1, maxAttempts);\n _delay = delay ?? ((d, ct) => Task.Delay(d, ct)); // injectable so tests don't actually sleep\n }\n\n public string Name => \"deepseek\";\n public bool SupportsTools => true;\n\n public async Task CompleteAsync(IReadOnlyList messages, IReadOnlyList tools, CancellationToken cancellationToken)\n {\n if (string.IsNullOrWhiteSpace(_apiKey)) throw new InvalidOperationException(\"No API key. Set DEEPSEEK_API_KEY.\");\n\n var payload = new Dictionary\n {\n [\"model\"] = _model,\n [\"messages\"] = messages,\n [\"stream\"] = false,\n [\"max_tokens\"] = _maxTokens\n };\n if (tools.Count > 0)\n {\n payload[\"tools\"] = tools.Select(t => new\n {\n type = \"function\",\n function = new { name = t.Name, description = t.Description, parameters = t.ParametersSchema }\n }).ToList();\n payload[\"tool_choice\"] = \"auto\";\n }\n\n // Serialize once; each attempt needs a fresh request/content (a sent one can't be reused).\n var json = JsonSerializer.Serialize(payload, Wire);\n InvalidOperationException? lastTransient = null;\n\n for (var attempt = 1; attempt <= _maxAttempts; attempt++)\n {\n using var request = new HttpRequestMessage(HttpMethod.Post, BuildEndpoint(_baseUrl));\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", _apiKey);\n request.Content = new StringContent(json, Encoding.UTF8, \"application/json\");\n\n HttpResponseMessage response;\n try\n {\n response = await _http.SendAsync(request, cancellationToken);\n }\n catch (HttpRequestException ex) // connection refused / DNS / TLS — transient, worth a retry\n {\n lastTransient = new InvalidOperationException(\"Network error talking to DeepSeek: \" + ex.Message, ex);\n if (attempt < _maxAttempts) { await Backoff(attempt, null, cancellationToken); continue; }\n throw lastTransient;\n }\n catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) // HttpClient timeout, not a user cancel\n {\n lastTransient = new InvalidOperationException(\"DeepSeek request timed out.\");\n if (attempt < _maxAttempts) { await Backoff(attempt, null, cancellationToken); continue; }\n throw lastTransient;\n }\n\n using (response)\n {\n var body = await response.Content.ReadAsStringAsync(cancellationToken);\n if (response.IsSuccessStatusCode) return Parse(body);\n\n // Retry rate-limit / transient server errors; fail fast on other 4xx (bad key, bad request).\n if (IsTransient(response.StatusCode) && attempt < _maxAttempts)\n {\n lastTransient = new InvalidOperationException($\"DeepSeek returned {(int)response.StatusCode}: {Trim(body)}\");\n await Backoff(attempt, response.Headers.RetryAfter, cancellationToken);\n continue;\n }\n throw new InvalidOperationException($\"DeepSeek returned {(int)response.StatusCode}: {Trim(body)}\");\n }\n }\n\n throw lastTransient ?? new InvalidOperationException(\"DeepSeek request failed.\");\n }\n\n private static bool IsTransient(System.Net.HttpStatusCode code) => (int)code switch\n {\n 408 or 429 or 500 or 502 or 503 or 504 => true,\n _ => false\n };\n\n // Exponential backoff, but honor a server Retry-After when present. Bounded so a hostile header\n // can't park the UI on \"Thinking...\" indefinitely.\n private async Task Backoff(int attempt, System.Net.Http.Headers.RetryConditionHeaderValue? retryAfter, CancellationToken ct)\n {\n TimeSpan delay;\n if (retryAfter?.Delta is TimeSpan d) delay = d;\n else if (retryAfter?.Date is DateTimeOffset when) delay = when - DateTimeOffset.UtcNow;\n else delay = TimeSpan.FromMilliseconds(400 * Math.Pow(2, attempt - 1));\n if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;\n if (delay > TimeSpan.FromSeconds(20)) delay = TimeSpan.FromSeconds(20);\n await _delay(delay, ct);\n }\n\n public static BackendReply Parse(string body) // public so it can be unit-tested directly\n {\n using var doc = JsonDocument.Parse(body);\n var root = doc.RootElement;\n\n // Some OpenAI-compatible providers return an error-shaped body with HTTP 200.\n if (root.TryGetProperty(\"error\", out var error))\n throw new InvalidOperationException(\"Provider error: \" +\n (error.TryGetProperty(\"message\", out var em) ? em.GetString() : error.ToString()));\n\n if (!root.TryGetProperty(\"choices\", out var choices) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0)\n throw new InvalidOperationException(\"Provider returned no choices.\");\n var choice = choices[0];\n if (!choice.TryGetProperty(\"message\", out var message))\n throw new InvalidOperationException(\"Provider returned no message.\");\n\n string? content = message.TryGetProperty(\"content\", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : null;\n\n List? toolCalls = null;\n if (message.TryGetProperty(\"tool_calls\", out var tc) && tc.ValueKind == JsonValueKind.Array && tc.GetArrayLength() > 0)\n {\n toolCalls = new List();\n foreach (var t in tc.EnumerateArray())\n {\n if (!t.TryGetProperty(\"function\", out var fn)) continue; // skip malformed tool calls\n toolCalls.Add(new ToolCall\n {\n Id = t.TryGetProperty(\"id\", out var id) ? id.GetString() ?? \"\" : \"\",\n Function = new ToolCallFunction\n {\n Name = fn.TryGetProperty(\"name\", out var n) ? n.GetString() ?? \"\" : \"\",\n Arguments = fn.TryGetProperty(\"arguments\", out var a) && a.ValueKind == JsonValueKind.String ? a.GetString() ?? \"{}\" : \"{}\"\n }\n });\n }\n if (toolCalls.Count == 0) toolCalls = null;\n }\n\n var finish = choice.TryGetProperty(\"finish_reason\", out var f) ? f.GetString() : null;\n // A truncated final answer (no tool calls) should not silently read as complete.\n if (finish == \"length\" && toolCalls is null && !string.IsNullOrEmpty(content))\n content += \"\\n\\n_[Response was cut off — ask me to continue.]_\";\n\n return new BackendReply\n {\n Message = new ChatMessage { Role = \"assistant\", Content = content, ToolCalls = toolCalls },\n FinishReason = finish\n };\n }\n\n private static Uri BuildEndpoint(string baseUrl)\n {\n var trimmed = baseUrl.Trim().TrimEnd('/');\n if (trimmed.EndsWith(\"/chat/completions\", StringComparison.OrdinalIgnoreCase)) return new Uri(trimmed);\n return new Uri($\"{trimmed}/chat/completions\");\n }\n\n private static string Trim(string s)\n {\n var clean = s.Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Trim();\n return clean[..Math.Min(400, clean.Length)];\n }\n}\n"}, "f29db3ee7c67010550d2cfcda885fd5bcd98c3a3": {"t": 1785756764.1343303, "n": 5760, "h": "1fa33124b8fb3184", "c": "using System.Net.Http.Headers;\nusing System.Text;\nusing System.Text.Json;\n\nnamespace CodexLocalRetrieval.Core.Chat;\n\n// OpenAI-compatible tool-calling backend (DeepSeek primary; also fits OpenAI). Non-streaming,\n// tool_choice=auto when tools are present (per DeepSeek's current tool-calls API). Owns only the HTTP\n// round-trip; the ChatOrchestrator owns the loop. Arguments produced by the model are NOT trusted\n// here — the orchestrator/tools validate them.\npublic sealed class DeepSeekBackend : IChatBackend\n{\n private static readonly JsonSerializerOptions Wire = new()\n {\n DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n };\n\n private readonly HttpClient _http;\n private readonly string _baseUrl;\n private readonly string _model;\n private readonly string _apiKey;\n private readonly int _maxTokens;\n\n public DeepSeekBackend(string baseUrl, string model, string apiKey, HttpClient? http = null, int maxTokens = 2000)\n {\n _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(90) };\n _baseUrl = baseUrl;\n _model = model;\n _apiKey = apiKey;\n _maxTokens = maxTokens;\n }\n\n public string Name => \"deepseek\";\n public bool SupportsTools => true;\n\n public async Task CompleteAsync(IReadOnlyList messages, IReadOnlyList tools, CancellationToken cancellationToken)\n {\n if (string.IsNullOrWhiteSpace(_apiKey)) throw new InvalidOperationException(\"No API key. Set DEEPSEEK_API_KEY.\");\n\n var payload = new Dictionary\n {\n [\"model\"] = _model,\n [\"messages\"] = messages,\n [\"stream\"] = false,\n [\"max_tokens\"] = _maxTokens\n };\n if (tools.Count > 0)\n {\n payload[\"tools\"] = tools.Select(t => new\n {\n type = \"function\",\n function = new { name = t.Name, description = t.Description, parameters = t.ParametersSchema }\n }).ToList();\n payload[\"tool_choice\"] = \"auto\";\n }\n\n using var request = new HttpRequestMessage(HttpMethod.Post, BuildEndpoint(_baseUrl));\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", _apiKey);\n request.Content = new StringContent(JsonSerializer.Serialize(payload, Wire), Encoding.UTF8, \"application/json\");\n\n using var response = await _http.SendAsync(request, cancellationToken);\n var body = await response.Content.ReadAsStringAsync(cancellationToken);\n if (!response.IsSuccessStatusCode)\n throw new InvalidOperationException($\"DeepSeek returned {(int)response.StatusCode}: {Trim(body)}\");\n\n return Parse(body);\n }\n\n internal static BackendReply Parse(string body)\n {\n using var doc = JsonDocument.Parse(body);\n var root = doc.RootElement;\n\n // Some OpenAI-compatible providers return an error-shaped body with HTTP 200.\n if (root.TryGetProperty(\"error\", out var error))\n throw new InvalidOperationException(\"Provider error: \" +\n (error.TryGetProperty(\"message\", out var em) ? em.GetString() : error.ToString()));\n\n if (!root.TryGetProperty(\"choices\", out var choices) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0)\n throw new InvalidOperationException(\"Provider returned no choices.\");\n var choice = choices[0];\n if (!choice.TryGetProperty(\"message\", out var message))\n throw new InvalidOperationException(\"Provider returned no message.\");\n\n string? content = message.TryGetProperty(\"content\", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : null;\n\n List? toolCalls = null;\n if (message.TryGetProperty(\"tool_calls\", out var tc) && tc.ValueKind == JsonValueKind.Array && tc.GetArrayLength() > 0)\n {\n toolCalls = new List();\n foreach (var t in tc.EnumerateArray())\n {\n if (!t.TryGetProperty(\"function\", out var fn)) continue; // skip malformed tool calls\n toolCalls.Add(new ToolCall\n {\n Id = t.TryGetProperty(\"id\", out var id) ? id.GetString() ?? \"\" : \"\",\n Function = new ToolCallFunction\n {\n Name = fn.TryGetProperty(\"name\", out var n) ? n.GetString() ?? \"\" : \"\",\n Arguments = fn.TryGetProperty(\"arguments\", out var a) && a.ValueKind == JsonValueKind.String ? a.GetString() ?? \"{}\" : \"{}\"\n }\n });\n }\n if (toolCalls.Count == 0) toolCalls = null;\n }\n\n var finish = choice.TryGetProperty(\"finish_reason\", out var f) ? f.GetString() : null;\n // A truncated final answer (no tool calls) should not silently read as complete.\n if (finish == \"length\" && toolCalls is null && !string.IsNullOrEmpty(content))\n content += \"\\n\\n_[Response was cut off — ask me to continue.]_\";\n\n return new BackendReply\n {\n Message = new ChatMessage { Role = \"assistant\", Content = content, ToolCalls = toolCalls },\n FinishReason = finish\n };\n }\n\n private static Uri BuildEndpoint(string baseUrl)\n {\n var trimmed = baseUrl.Trim().TrimEnd('/');\n if (trimmed.EndsWith(\"/chat/completions\", StringComparison.OrdinalIgnoreCase)) return new Uri(trimmed);\n return new Uri($\"{trimmed}/chat/completions\");\n }\n\n private static string Trim(string s)\n {\n var clean = s.Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Trim();\n return clean[..Math.Min(400, clean.Length)];\n }\n}\n"}, "ce87a735376ce0fd66f446d3784a25a6d08c2184": {"t": 1785756765.4648743, "n": 4726, "h": "59c29214cbdfe289", "c": "using System.Net.Http.Headers;\nusing System.Text;\nusing System.Text.Json;\n\nnamespace CodexLocalRetrieval.Core.Chat;\n\n// OpenAI-compatible tool-calling backend (DeepSeek primary; also fits OpenAI). Non-streaming,\n// tool_choice=auto when tools are present (per DeepSeek's current tool-calls API). Owns only the HTTP\n// round-trip; the ChatOrchestrator owns the loop. Arguments produced by the model are NOT trusted\n// here — the orchestrator/tools validate them.\npublic sealed class DeepSeekBackend : IChatBackend\n{\n private static readonly JsonSerializerOptions Wire = new()\n {\n DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull\n };\n\n private readonly HttpClient _http;\n private readonly string _baseUrl;\n private readonly string _model;\n private readonly string _apiKey;\n private readonly int _maxTokens;\n\n public DeepSeekBackend(string baseUrl, string model, string apiKey, HttpClient? http = null, int maxTokens = 1200)\n {\n _http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(90) };\n _baseUrl = baseUrl;\n _model = model;\n _apiKey = apiKey;\n _maxTokens = maxTokens;\n }\n\n public string Name => \"deepseek\";\n public bool SupportsTools => true;\n\n public async Task CompleteAsync(IReadOnlyList messages, IReadOnlyList tools, CancellationToken cancellationToken)\n {\n if (string.IsNullOrWhiteSpace(_apiKey)) throw new InvalidOperationException(\"No API key. Set DEEPSEEK_API_KEY.\");\n\n var payload = new Dictionary\n {\n [\"model\"] = _model,\n [\"messages\"] = messages,\n [\"stream\"] = false,\n [\"max_tokens\"] = _maxTokens\n };\n if (tools.Count > 0)\n {\n payload[\"tools\"] = tools.Select(t => new\n {\n type = \"function\",\n function = new { name = t.Name, description = t.Description, parameters = t.ParametersSchema }\n }).ToList();\n payload[\"tool_choice\"] = \"auto\";\n }\n\n using var request = new HttpRequestMessage(HttpMethod.Post, BuildEndpoint(_baseUrl));\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", _apiKey);\n request.Content = new StringContent(JsonSerializer.Serialize(payload, Wire), Encoding.UTF8, \"application/json\");\n\n using var response = await _http.SendAsync(request, cancellationToken);\n var body = await response.Content.ReadAsStringAsync(cancellationToken);\n if (!response.IsSuccessStatusCode)\n throw new InvalidOperationException($\"DeepSeek returned {(int)response.StatusCode}: {Trim(body)}\");\n\n return Parse(body);\n }\n\n internal static BackendReply Parse(string body)\n {\n using var doc = JsonDocument.Parse(body);\n var choice = doc.RootElement.GetProperty(\"choices\")[0];\n var message = choice.GetProperty(\"message\");\n\n string? content = message.TryGetProperty(\"content\", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() : null;\n\n List? toolCalls = null;\n if (message.TryGetProperty(\"tool_calls\", out var tc) && tc.ValueKind == JsonValueKind.Array && tc.GetArrayLength() > 0)\n {\n toolCalls = new List();\n foreach (var t in tc.EnumerateArray())\n {\n var fn = t.GetProperty(\"function\");\n toolCalls.Add(new ToolCall\n {\n Id = t.TryGetProperty(\"id\", out var id) ? id.GetString() ?? \"\" : \"\",\n Function = new ToolCallFunction\n {\n Name = fn.TryGetProperty(\"name\", out var n) ? n.GetString() ?? \"\" : \"\",\n Arguments = fn.TryGetProperty(\"arguments\", out var a) ? a.GetString() ?? \"{}\" : \"{}\"\n }\n });\n }\n }\n\n var finish = choice.TryGetProperty(\"finish_reason\", out var f) ? f.GetString() : null;\n return new BackendReply\n {\n Message = new ChatMessage { Role = \"assistant\", Content = content, ToolCalls = toolCalls },\n FinishReason = finish\n };\n }\n\n private static Uri BuildEndpoint(string baseUrl)\n {\n var trimmed = baseUrl.Trim().TrimEnd('/');\n if (trimmed.EndsWith(\"/chat/completions\", StringComparison.OrdinalIgnoreCase)) return new Uri(trimmed);\n return new Uri($\"{trimmed}/chat/completions\");\n }\n\n private static string Trim(string s)\n {\n var clean = s.Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Trim();\n return clean[..Math.Min(400, clean.Length)];\n }\n}\n"}}, "native/CodexLocalRetrieval.Core/Services/ArchiveService.cs": {"0bd60df9ea622f66f6e6720e6824fe5916840a06": {"t": 1785746207.0388496, "n": 137857, "h": "e7513021c2d1c392", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Memory;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 10; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n EnsureDecks();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n try\n {\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n }\n catch (Exception ex)\n {\n session.Messages = new ObservableCollection\n {\n new()\n {\n Id = \"load-error\",\n Role = \"assistant\",\n Timestamp = session.UpdatedAt,\n Text = $\"Could not load the full transcript from disk.\\n\\n{ex.Message}\\n\\nSource: {path}\"\n }\n };\n session.CodeBlocks = new ObservableCollection();\n session.MessageCount = session.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n Decks = new List(Store.Decks),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n TagColors = new Dictionary(Store.TagColors),\n TagLayers = new Dictionary(Store.TagLayers),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? MainDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n }\n else if (string.IsNullOrWhiteSpace(collection.DeckId)) collection.DeckId = deck;\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? ActiveDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n // Add a chat to an EXISTING collection by its id (used by the \"Add to collection\" menus, which\n // already know the collection - avoids any name/deck ambiguity).\n public async Task AddToCollectionByIdAsync(ArchiveSession session, string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n if (!col.SessionIds.Contains(session.Id)) col.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // ---- Decks (top-level groupings of collections) --------------------------------------------\n public const string MainDeckId = \"main\";\n\n public void EnsureDecks()\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, MainDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Decks.Insert(0, new Deck { Id = MainDeckId, Name = \"Main\" });\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId)\n || !Store.Decks.Any(d => string.Equals(d.Id, Store.Settings.ActiveDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.ActiveDeckId = MainDeckId;\n }\n\n public IReadOnlyList Decks => Store.Decks;\n public string ActiveDeckId => string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId) ? MainDeckId : Store.Settings.ActiveDeckId;\n public static string CollectionDeck(ArchiveCollection c) => string.IsNullOrWhiteSpace(c.DeckId) ? MainDeckId : c.DeckId;\n\n public IReadOnlyList CollectionsInDeck(string deckId) =>\n Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)).ToList();\n\n public int CollectionCountInDeck(string deckId) =>\n Store.Collections.Values.Count(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase));\n\n // Resolve a deck reference (id OR name, case-insensitive) to a deck id; null/unknown -> main.\n public string ResolveDeckId(string? deckRef)\n {\n if (string.IsNullOrWhiteSpace(deckRef)) return MainDeckId;\n var d = Store.Decks.FirstOrDefault(x =>\n string.Equals(x.Id, deckRef, StringComparison.OrdinalIgnoreCase) ||\n string.Equals(x.Name, deckRef, StringComparison.OrdinalIgnoreCase));\n return d?.Id ?? MainDeckId;\n }\n\n private string DeckCollectionId(string name, string deckId) =>\n string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase) ? Slug(name) : $\"{Slug(deckId)}--{Slug(name)}\";\n\n public async Task CreateDeckAsync(string name)\n {\n var id = UniqueDeckId(name);\n var deck = new Deck { Id = id, Name = name.Trim(), CreatedAt = DateTime.UtcNow.ToString(\"O\") };\n Store.Decks.Add(deck);\n await SaveAsync();\n return deck;\n }\n\n private string UniqueDeckId(string name)\n {\n var baseId = Slug(name);\n if (string.Equals(baseId, MainDeckId, StringComparison.OrdinalIgnoreCase)) baseId = \"deck-\" + baseId;\n var id = baseId;\n var n = 2;\n while (Store.Decks.Any(d => string.Equals(d.Id, id, StringComparison.OrdinalIgnoreCase))) id = $\"{baseId}-{n++}\";\n return id;\n }\n\n public async Task RenameDeckAsync(string deckId, string name)\n {\n var d = Store.Decks.FirstOrDefault(x => string.Equals(x.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (d is null || string.IsNullOrWhiteSpace(name)) return;\n d.Name = name.Trim();\n await SaveAsync();\n }\n\n // Delete a deck (never Main) - its collections move to Main so nothing is orphaned.\n public async Task DeleteDeckAsync(string deckId)\n {\n if (string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase)) return;\n foreach (var c in Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)))\n c.DeckId = MainDeckId;\n Store.Decks.RemoveAll(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (string.Equals(Store.Settings.ActiveDeckId, deckId, StringComparison.OrdinalIgnoreCase))\n Store.Settings.ActiveDeckId = MainDeckId;\n await SaveAsync();\n }\n\n public async Task SetActiveDeckAsync(string deckId)\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n Store.Settings.ActiveDeckId = deckId;\n await SaveAsync();\n }\n\n public async Task MoveCollectionToDeckAsync(string collectionId, string deckId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return;\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n c.DeckId = ResolveDeckId(deckId);\n await SaveAsync();\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Per-collection Memory Bank / Project Brain vaults live here, a sibling of the backups dir.\n public string BrainsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"brains\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n var deck = ResolveDeckId(cmd.deck);\n await AddToCollectionAsync(s, cmd.project!, deck);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var colId = DeckCollectionId(cmd.project!, deck);\n var persisted = Store.Collections.TryGetValue(colId, out var collection)\n && collection.SessionIds.Contains(s.Id);\n var deckName = Store.Decks.FirstOrDefault(d => string.Equals(d.Id, deck, StringComparison.OrdinalIgnoreCase))?.Name ?? \"Main\";\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\" on deck \\\"{deckName}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n case \"tag\":\n case \"untag\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(op), inputId);\n var tags = (cmd.tags ?? new List()).ToList();\n if (!string.IsNullOrWhiteSpace(cmd.name)) tags.Add(cmd.name!);\n tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();\n if (tags.Count == 0) return new AgentCommandResult(false, $\"{op} needs 'tags' (array) or 'name'.\", inputId, s.Id);\n var changed = 0;\n foreach (var t in tags)\n changed += (op == \"tag\" ? AddTagTo(s.Tags, t, reserved: true) : RemoveTagFrom(s.Tags, t)) ? 1 : 0;\n if (changed > 0) { await SaveAsync(); RefreshSessions(Store.Sessions.Values); }\n var verb = op == \"tag\" ? \"Tagged\" : \"Untagged\";\n return new AgentCommandResult(true, $\"{verb} \\\"{s.DisplayTitle}\\\" ({changed} change{(changed == 1 ? \"\" : \"s\")}).\", inputId, s.Id, Persisted: changed > 0);\n }\n\n case \"handoff\":\n {\n // Return the collection's brain as a compact, sparse handoff bundle (current state +\n // canonical truths + open loops + working notes, each with a source link). No LLM, no\n // key — reads the canonical vault. Optional cmd.name carries the task to orient on.\n if (string.IsNullOrWhiteSpace(cmd.project))\n return new AgentCommandResult(false, \"handoff needs 'project' (the collection name).\");\n var colId = ResolveCollectionIdByName(cmd.project!, cmd.deck);\n if (colId is null) return new AgentCommandResult(false, $\"No collection named \\\"{cmd.project}\\\".\", Project: cmd.project);\n var brain = new BrainService(this);\n if (!brain.PathsForBuilt(colId))\n return new AgentCommandResult(false, $\"No brain built yet for \\\"{cmd.project}\\\" — build it in the app first.\", Project: cmd.project);\n var bundle = brain.GetAgentContext(colId, cmd.name ?? \"\");\n return new AgentCommandResult(true, bundle, Project: cmd.project, Persisted: true);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n // Resolve a collection by the agent-supplied project name: first the deck-scoped id, then a\n // case-insensitive name match across decks. Null if no such collection exists.\n private string? ResolveCollectionIdByName(string name, string? deckRef)\n {\n var deck = ResolveDeckId(deckRef);\n var colId = DeckCollectionId(name, deck);\n if (Store.Collections.ContainsKey(colId)) return colId;\n var match = Store.Collections.Values.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase));\n return match?.Id;\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"handoff\" or \"agentcontext\" or \"brainhandoff\" or \"context\" => \"handoff\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return await RefreshIndexedSessionAsync(s, ExplicitId(cmd), cmd.tool);\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Exact-id/self commands must not act on stale metadata. A long Claude session can keep the same\n // resumable id while new turns are appended far past the original indexed window, so refresh the\n // matched source file before filing/favoriting/naming it.\n private async Task RefreshIndexedSessionAsync(ArchiveSession existing, string? expectedId, string? tool)\n {\n try\n {\n var path = string.IsNullOrWhiteSpace(existing.SourcePath) ? \"\"\n : Path.IsPathRooted(existing.SourcePath) ? existing.SourcePath : Path.Combine(_rootPath, existing.SourcePath);\n if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return existing;\n\n var parsed = await ParseSessionAsync(path, string.IsNullOrWhiteSpace(tool) ? existing.Tool : tool!);\n if (parsed is null) return existing;\n\n var targetId = !string.IsNullOrWhiteSpace(expectedId) ? expectedId! : existing.Id;\n if (!SessionHasIdOrAlias(parsed, targetId) && !string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase))\n return existing;\n\n PreserveAppFields(existing, parsed);\n if (!string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase)) Store.Sessions.Remove(existing.Id);\n Store.Sessions[parsed.Id] = parsed;\n\n var info = new FileInfo(path);\n Store.FileStamps[path] = info.LastWriteTimeUtc.Ticks + \":\" + info.Length;\n return parsed;\n }\n catch\n {\n return existing;\n }\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n // ---- Tagging & filtering -------------------------------------------------------------------\n // Reserved tags are auto-managed (archive = \"lives in the archive\", code = \"has code blocks\") and\n // are hidden from the tag UI/filters so user tags stay clean. Everything else is a user tag.\n public static readonly IReadOnlyCollection ReservedTags =\n new HashSet(StringComparer.OrdinalIgnoreCase) { \"archive\", \"code\" };\n\n public static bool IsReservedTag(string? tag) => !string.IsNullOrWhiteSpace(tag) && ReservedTags.Contains(tag.Trim());\n\n public static string NormalizeTag(string? tag)\n {\n var t = Regex.Replace((tag ?? \"\").Trim(), \"\\\\s+\", \" \");\n return t.Length > 40 ? t[..40].Trim() : t;\n }\n\n // The user-facing tags on a chat (reserved tags removed), sorted, de-duped case-insensitively.\n public static IReadOnlyList UserTags(ArchiveSession session) =>\n session.Tags\n .Where(t => !string.IsNullOrWhiteSpace(t) && !IsReservedTag(t))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public async Task AddChatTagAsync(ArchiveSession session, string tag)\n {\n if (!AddTagTo(session.Tags, tag, reserved: true)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RemoveChatTagAsync(ArchiveSession session, string tag)\n {\n if (!RemoveTagFrom(session.Tags, tag)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task AddCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!AddTagTo(c.Tags, tag, reserved: false)) return false;\n await SaveAsync();\n return true;\n }\n\n public async Task RemoveCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!RemoveTagFrom(c.Tags, tag)) return false;\n await SaveAsync();\n return true;\n }\n\n // All user chat tags with counts (most-used first, then alpha) - drives the chat filter strip.\n public IReadOnlyList AllChatTags() =>\n Store.Sessions.Values\n .Where(s => !s.Archived)\n .SelectMany(UserTags)\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public IReadOnlyList AllCollectionTags() =>\n Store.Collections.Values\n .SelectMany(c => c.Tags.Where(t => !string.IsNullOrWhiteSpace(t)))\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n // Back-compat thin wrapper: text + an ANY/ALL include-tag filter.\n public IReadOnlyList Filter(string query, IReadOnlyCollection? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty()).ToList(), MatchAllIncludes = matchAll });\n\n private static bool SessionHasTag(ArchiveSession s, string tag) =>\n s.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // The compound filter: text search, then restrict to a collection (if set), then keep chats that\n // satisfy the include set (ANY or ALL) and carry NONE of the exclude set.\n public IReadOnlyList FilterChats(ChatFilter f)\n {\n IEnumerable baseSet = string.IsNullOrWhiteSpace(f.Query)\n ? OrderedVisibleSessions(Store.Sessions.Values)\n : Search(f.Query);\n\n if (!string.IsNullOrEmpty(f.CollectionId) && Store.Collections.TryGetValue(f.CollectionId, out var col))\n {\n var ids = new HashSet(col.SessionIds, StringComparer.OrdinalIgnoreCase);\n baseSet = baseSet.Where(s => ids.Contains(s.Id));\n }\n\n return baseSet\n .Where(s => f.IncludeTags.Count == 0\n || (f.MatchAllIncludes\n ? f.IncludeTags.All(t => SessionHasTag(s, t))\n : f.IncludeTags.Any(t => SessionHasTag(s, t))))\n .Where(s => f.ExcludeTags.Count == 0 || !f.ExcludeTags.Any(t => SessionHasTag(s, t)))\n .ToList();\n }\n\n // ---- Collection (project) filtering + demote ordering --------------------------------------\n private static bool CollectionHasTag(ArchiveCollection c, string tag) =>\n c.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // Filter collections by their own tags (compound): keep those satisfying the include set (ANY or\n // ALL) and carrying NONE of the exclude set. \"active + graphics, not web-dev\" -> include both, exclude web-dev.\n public IReadOnlyList FilterCollections(IReadOnlyCollection include, IReadOnlyCollection exclude, bool matchAll = false)\n {\n return Store.Collections.Values\n .Where(c => include.Count == 0\n || (matchAll ? include.All(t => CollectionHasTag(c, t)) : include.Any(t => CollectionHasTag(c, t))))\n .Where(c => exclude.Count == 0 || !exclude.Any(t => CollectionHasTag(c, t)))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n }\n\n // Layer rules: a tag can be assigned a layer (LOWER number = HIGHER in the list). Unassigned tags\n // use DefaultLayer, so layered tags can float above (e.g. active=1) or sink below (e.g. context=900).\n public const int DefaultLayer = 100;\n\n public int TagLayer(string tag) =>\n Store.TagLayers.TryGetValue(TagColorKey(tag), out var n) ? n : DefaultLayer;\n\n // A chat's layer = the strongest (lowest) EXPLICITLY-assigned layer among its tags; DefaultLayer\n // if none of its tags has a layer rule. (Unlayered tags don't pull a context chat back to the middle.)\n public int SessionLayer(ArchiveSession s)\n {\n int? best = null;\n foreach (var t in UserTags(s))\n if (Store.TagLayers.TryGetValue(TagColorKey(t), out var l))\n best = best is null ? l : Math.Min(best.Value, l);\n return best ?? DefaultLayer;\n }\n\n public async Task SetTagLayerAsync(string tag, int? layer)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (layer is null || layer == DefaultLayer) Store.TagLayers.Remove(key);\n else Store.TagLayers[key] = layer.Value;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Order a collection's chats: pinned first, then by layer (active up / context down), then by the\n // collection's MANUAL order (its SessionIds sequence), then recency. So manual drags persist and\n // layer rules group on top of them.\n public IReadOnlyList OrderCollectionChats(ArchiveCollection col, IEnumerable sessions)\n {\n var manual = new Dictionary(StringComparer.OrdinalIgnoreCase);\n for (var i = 0; i < col.SessionIds.Count; i++) manual.TryAdd(col.SessionIds[i], i);\n return sessions\n .OrderByDescending(s => s.Pinned)\n .ThenBy(s => SessionLayer(s))\n .ThenBy(s => manual.TryGetValue(s.Id, out var i) ? i : int.MaxValue)\n .ThenByDescending(s => s.UpdatedAt)\n .ToList();\n }\n\n // Move a chat within a collection's manual order by delta (negative = up). toEnd jumps to top/bottom.\n public async Task ReorderInCollectionAsync(string collectionId, string sessionId, int delta, bool toEnd = false)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n var i = col.SessionIds.FindIndex(x => string.Equals(x, sessionId, StringComparison.OrdinalIgnoreCase));\n if (i < 0) return;\n var target = toEnd ? (delta < 0 ? 0 : col.SessionIds.Count - 1) : Math.Clamp(i + delta, 0, col.SessionIds.Count - 1);\n if (target == i) return;\n col.SessionIds.RemoveAt(i);\n col.SessionIds.Insert(target, sessionId);\n await SaveAsync();\n }\n\n // ---- Tag colors ----------------------------------------------------------------------------\n // A curated palette that reads on pure-black AMOLED and coexists with the rose accent (Tailwind-400\n // family). Tags get a deterministic auto-color from this by name hash; users can override per tag.\n // Rose (#FB7185) is deliberately excluded - it's reserved for the accent / active-filter state.\n public static readonly IReadOnlyList TagPalette = new[]\n {\n \"#60A5FA\", \"#38BDF8\", \"#22D3EE\", \"#2DD4BF\", \"#34D399\",\n \"#A3E635\", \"#FBBF24\", \"#FB923C\", \"#F87171\", \"#A78BFA\"\n };\n\n private static string TagColorKey(string tag) => NormalizeTag(tag).ToLowerInvariant();\n\n // Stable across runs (string.GetHashCode is randomized in .NET, so use FNV-1a).\n private static uint Fnv1a(string s)\n {\n uint hash = 2166136261;\n foreach (var ch in s) { hash ^= ch; hash *= 16777619; }\n return hash;\n }\n\n public static string AutoTagColor(string tag)\n {\n var key = NormalizeTag(tag).ToLowerInvariant();\n if (key.Length == 0) return TagPalette[0];\n return TagPalette[(int)(Fnv1a(key) % (uint)TagPalette.Count)];\n }\n\n // The effective color for a tag: a user override if set, else the deterministic auto-color.\n public string TagColor(string tag)\n {\n var key = TagColorKey(tag);\n return Store.TagColors.TryGetValue(key, out var hex) && !string.IsNullOrWhiteSpace(hex)\n ? hex\n : AutoTagColor(tag);\n }\n\n public bool HasCustomTagColor(string tag) => Store.TagColors.ContainsKey(TagColorKey(tag));\n\n // Set (or clear, when hex is null/blank) a tag's color override; clearing returns it to auto.\n public async Task SetTagColorAsync(string tag, string? hex)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (string.IsNullOrWhiteSpace(hex)) Store.TagColors.Remove(key);\n else Store.TagColors[key] = hex.Trim();\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Generic add/remove against either tag store (ObservableCollection for chats, List for collections).\n private static bool AddTagTo(ICollection store, string tag, bool reserved)\n {\n var t = NormalizeTag(tag);\n if (t.Length == 0 || (reserved && IsReservedTag(t))) return false;\n if (store.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) return false;\n store.Add(t);\n return true;\n }\n\n private static bool RemoveTagFrom(ICollection store, string tag)\n {\n var match = store.FirstOrDefault(x => string.Equals(x, (tag ?? \"\").Trim(), StringComparison.OrdinalIgnoreCase));\n if (match is null) return false;\n store.Remove(match);\n return true;\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n // Auto-detect per file so a root holding Claude transcripts (or a mix) is routed correctly\n // instead of being force-parsed as Codex — otherwise a Claude .jsonl yields zero messages and\n // is mis-tagged tool=codex (which then breaks ParseFullAsync / the brain source layer).\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"auto\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // The shared, FULL-history result of parsing one transcript, BEFORE the recent-message window is\n // applied. The normal indexing path (ParseJsonlAsync/ParseClaudeSessionAsync) wraps this and trims\n // to the window; the brain wants the untrimmed list for stable anchoring. ONE source of truth for\n // message extraction — a drift-guard test asserts the windowed tail equals this list's tail.\n private sealed record ParsedTranscript(\n ObservableCollection Messages,\n ObservableCollection CodeBlocks,\n string Id, string Title, string Created, string Updated,\n string Cwd, HashSet Aliases, int Total, string Tool);\n\n // Brain source layer: re-parse a session's raw transcript WITHOUT the 600-message window, yielding\n // the full ordered message list with a stable per-message Index (0-based over the whole transcript)\n // — the basis for source blocks and anchors. Reuses the exact same extraction core as indexing.\n public async Task> ParseFullAsync(ArchiveSession session)\n {\n if (session is null || string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath))\n return Array.Empty();\n var tool = string.IsNullOrWhiteSpace(session.Tool) || session.Tool == \"auto\"\n ? DetectTool(session.SourcePath) : session.Tool;\n var core = tool == \"claude\"\n ? await ParseClaudeCoreAsync(session.SourcePath)\n : await ParseCodexCoreAsync(session.SourcePath);\n if (core is null) return Array.Empty();\n var list = new List(core.Messages.Count);\n for (var i = 0; i < core.Messages.Count; i++)\n {\n var m = core.Messages[i];\n list.Add(new FullMessage(i, m.Role, m.Text, m.Timestamp, m.EffectiveKind == \"tool\" ? m.ToolName : null));\n }\n return list;\n }\n\n // Brain \"open source span\": the messages of a transcript in the inclusive index range a card's\n // anchor points at (optionally with N messages of context on each side). Indices are over the FULL\n // history, matching the anchors SourceBlocker produced. Clamped, so a stale anchor never throws.\n public async Task> ParseFullRangeAsync(ArchiveSession session, int startIndex, int endIndex, int context = 0)\n {\n var full = await ParseFullAsync(session);\n if (full.Count == 0) return full;\n var lo = Math.Max(0, Math.Min(startIndex, endIndex) - Math.Max(0, context));\n var hi = Math.Min(full.Count - 1, Math.Max(startIndex, endIndex) + Math.Max(0, context));\n var slice = new List(Math.Max(0, hi - lo + 1));\n for (var i = lo; i <= hi; i++) slice.Add(full[i]);\n return slice;\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null, string? extraArgsOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n // Claude resume is directory-scoped (it only finds the session under the launch dir's encoded\n // project folder), so it needs the recovered launch dir — not the recorded workspace subdir.\n var cwd = isClaude ? ResolveClaudeResumeDirectory(session) : ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var baseArgs = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n // Optional user-configured launch args (e.g. \"--profile http_sse\" for Codex) go in the PREFIX\n // position — right after the exe, before the subcommand — so global flags apply (Codex requires\n // --profile before `resume`). Empty leaves the default command exactly as it was. Newlines are\n // stripped so a stray paste can't break the cmd line; this is the user's own local config.\n var extra = (extraArgsOverride ?? (isClaude ? Store.Settings.ClaudeLaunchArgs : Store.Settings.CodexLaunchArgs) ?? \"\")\n .Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Trim();\n var args = string.IsNullOrEmpty(extra) ? baseArgs : $\"{extra} {baseArgs}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n // Claude Code files a transcript under a project folder named by DASH-ENCODING the directory that\n // `claude` was launched from (each \\ / : . and whitespace becomes '-'). `claude --resume `\n // only finds the session when run from THAT directory. The cwd recorded inside the transcript can\n // be a SUBDIR of the launch dir (e.g. launched in …\\301, cwd later moved to …\\301\\graphics), so\n // resuming from the recorded workspace fails with \"No conversation found with session ID\". Recover\n // the real launch dir by climbing from the workspace until a directory's encoding matches the\n // transcript's project-folder name. Falls back to the recorded workspace if nothing matches.\n private static string ResolveClaudeResumeDirectory(ArchiveSession session)\n {\n var fallback = ResolveWorkingDirectory(session);\n var projectFolder = Path.GetFileName(Path.GetDirectoryName(session.SourcePath) ?? \"\");\n if (string.IsNullOrWhiteSpace(projectFolder)) return fallback;\n\n foreach (var start in new[] { session.Workspace, fallback })\n {\n var dir = string.IsNullOrWhiteSpace(start) ? null : start;\n for (var guard = 0; guard < 64 && !string.IsNullOrWhiteSpace(dir); guard++)\n {\n if (string.Equals(EncodeClaudeProjectFolder(dir!), projectFolder, StringComparison.OrdinalIgnoreCase)\n && Directory.Exists(dir!))\n return dir!;\n dir = Path.GetDirectoryName(dir!.TrimEnd('\\\\', '/'));\n }\n }\n return fallback;\n }\n\n // Mirror Claude Code's project-folder encoding: every path separator, drive colon, dot, or\n // whitespace char becomes '-', preserving case and any literal dashes already in the path.\n private static string EncodeClaudeProjectFolder(string path) =>\n Regex.Replace(path.TrimEnd('\\\\', '/'), @\"[\\\\/:.\\s]\", \"-\");\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return ElementText(v);\n }\n\n private static string ElementText(JsonElement v) =>\n v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n\n private static string ElementString(JsonElement v) =>\n v.ValueKind == JsonValueKind.String ? v.GetString() ?? \"\" : \"\";\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseCodexCoreAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? ElementString(rtProp) : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && ElementString(sidProp) is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && ElementString(midProp) is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && ElementString(forkProp) is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp) && ElementString(cwdProp) is { Length: > 0 } cw) cwd = cw;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? ElementString(typeProp) : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = Field(payload, \"message\");\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = Field(payload, \"name\");\n if (string.IsNullOrWhiteSpace(name)) name = \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = Field(payload, \"output\");\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = Field(payload, \"role\");\n if (string.IsNullOrWhiteSpace(role)) role = \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ParsedTranscript(messages, codeBlocks, id, title, created, updated, cwd, aliases, messages.Count, \"codex\");\n }\n\n // Thin wrapper: full Codex parse, then trim to the recent-message window for the in-app reader.\n private async Task ParseJsonlAsync(string filePath)\n {\n var p = await ParseCodexCoreAsync(filePath);\n var (messages, codeBlocks) = KeepRecentWindow(p.Messages, p.CodeBlocks);\n return new ArchiveSession\n {\n Id = p.Id,\n Title = p.Title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(p.Aliases.Where(a => !string.Equals(a, p.Id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = p.Created,\n UpdatedAt = p.Updated,\n Workspace = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown workspace\" : p.Cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown\" : Path.GetFileName(p.Cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = p.Total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeCoreAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n return new ParsedTranscript(messages, codeBlocks, id, title, created, updated, cwd,\n new HashSet(StringComparer.OrdinalIgnoreCase), messages.Count, \"claude\");\n }\n\n // Thin wrapper: full Claude parse (null = sidechain/subagent file), then trim to the reader window.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var p = await ParseClaudeCoreAsync(filePath);\n if (p is null) return null;\n var (messages, codeBlocks) = KeepRecentWindow(p.Messages, p.CodeBlocks);\n return new ArchiveSession\n {\n Id = p.Id,\n Title = p.Title,\n SourcePath = filePath,\n CreatedAt = p.Created,\n UpdatedAt = p.Updated,\n Workspace = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown workspace\" : p.Cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown\" : Path.GetFileName(p.Cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = p.Total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = ElementText(argsProp);\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "987d1fe073ee9f8d782c2352880fa7a513611a55": {"t": 1785756764.387136, "n": 137857, "h": "e7513021c2d1c392", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Memory;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 10; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n EnsureDecks();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n try\n {\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n }\n catch (Exception ex)\n {\n session.Messages = new ObservableCollection\n {\n new()\n {\n Id = \"load-error\",\n Role = \"assistant\",\n Timestamp = session.UpdatedAt,\n Text = $\"Could not load the full transcript from disk.\\n\\n{ex.Message}\\n\\nSource: {path}\"\n }\n };\n session.CodeBlocks = new ObservableCollection();\n session.MessageCount = session.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n Decks = new List(Store.Decks),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n TagColors = new Dictionary(Store.TagColors),\n TagLayers = new Dictionary(Store.TagLayers),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? MainDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n }\n else if (string.IsNullOrWhiteSpace(collection.DeckId)) collection.DeckId = deck;\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? ActiveDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n // Add a chat to an EXISTING collection by its id (used by the \"Add to collection\" menus, which\n // already know the collection - avoids any name/deck ambiguity).\n public async Task AddToCollectionByIdAsync(ArchiveSession session, string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n if (!col.SessionIds.Contains(session.Id)) col.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // ---- Decks (top-level groupings of collections) --------------------------------------------\n public const string MainDeckId = \"main\";\n\n public void EnsureDecks()\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, MainDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Decks.Insert(0, new Deck { Id = MainDeckId, Name = \"Main\" });\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId)\n || !Store.Decks.Any(d => string.Equals(d.Id, Store.Settings.ActiveDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.ActiveDeckId = MainDeckId;\n }\n\n public IReadOnlyList Decks => Store.Decks;\n public string ActiveDeckId => string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId) ? MainDeckId : Store.Settings.ActiveDeckId;\n public static string CollectionDeck(ArchiveCollection c) => string.IsNullOrWhiteSpace(c.DeckId) ? MainDeckId : c.DeckId;\n\n public IReadOnlyList CollectionsInDeck(string deckId) =>\n Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)).ToList();\n\n public int CollectionCountInDeck(string deckId) =>\n Store.Collections.Values.Count(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase));\n\n // Resolve a deck reference (id OR name, case-insensitive) to a deck id; null/unknown -> main.\n public string ResolveDeckId(string? deckRef)\n {\n if (string.IsNullOrWhiteSpace(deckRef)) return MainDeckId;\n var d = Store.Decks.FirstOrDefault(x =>\n string.Equals(x.Id, deckRef, StringComparison.OrdinalIgnoreCase) ||\n string.Equals(x.Name, deckRef, StringComparison.OrdinalIgnoreCase));\n return d?.Id ?? MainDeckId;\n }\n\n private string DeckCollectionId(string name, string deckId) =>\n string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase) ? Slug(name) : $\"{Slug(deckId)}--{Slug(name)}\";\n\n public async Task CreateDeckAsync(string name)\n {\n var id = UniqueDeckId(name);\n var deck = new Deck { Id = id, Name = name.Trim(), CreatedAt = DateTime.UtcNow.ToString(\"O\") };\n Store.Decks.Add(deck);\n await SaveAsync();\n return deck;\n }\n\n private string UniqueDeckId(string name)\n {\n var baseId = Slug(name);\n if (string.Equals(baseId, MainDeckId, StringComparison.OrdinalIgnoreCase)) baseId = \"deck-\" + baseId;\n var id = baseId;\n var n = 2;\n while (Store.Decks.Any(d => string.Equals(d.Id, id, StringComparison.OrdinalIgnoreCase))) id = $\"{baseId}-{n++}\";\n return id;\n }\n\n public async Task RenameDeckAsync(string deckId, string name)\n {\n var d = Store.Decks.FirstOrDefault(x => string.Equals(x.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (d is null || string.IsNullOrWhiteSpace(name)) return;\n d.Name = name.Trim();\n await SaveAsync();\n }\n\n // Delete a deck (never Main) - its collections move to Main so nothing is orphaned.\n public async Task DeleteDeckAsync(string deckId)\n {\n if (string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase)) return;\n foreach (var c in Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)))\n c.DeckId = MainDeckId;\n Store.Decks.RemoveAll(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (string.Equals(Store.Settings.ActiveDeckId, deckId, StringComparison.OrdinalIgnoreCase))\n Store.Settings.ActiveDeckId = MainDeckId;\n await SaveAsync();\n }\n\n public async Task SetActiveDeckAsync(string deckId)\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n Store.Settings.ActiveDeckId = deckId;\n await SaveAsync();\n }\n\n public async Task MoveCollectionToDeckAsync(string collectionId, string deckId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return;\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n c.DeckId = ResolveDeckId(deckId);\n await SaveAsync();\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Per-collection Memory Bank / Project Brain vaults live here, a sibling of the backups dir.\n public string BrainsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"brains\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n var deck = ResolveDeckId(cmd.deck);\n await AddToCollectionAsync(s, cmd.project!, deck);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var colId = DeckCollectionId(cmd.project!, deck);\n var persisted = Store.Collections.TryGetValue(colId, out var collection)\n && collection.SessionIds.Contains(s.Id);\n var deckName = Store.Decks.FirstOrDefault(d => string.Equals(d.Id, deck, StringComparison.OrdinalIgnoreCase))?.Name ?? \"Main\";\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\" on deck \\\"{deckName}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n case \"tag\":\n case \"untag\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(op), inputId);\n var tags = (cmd.tags ?? new List()).ToList();\n if (!string.IsNullOrWhiteSpace(cmd.name)) tags.Add(cmd.name!);\n tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();\n if (tags.Count == 0) return new AgentCommandResult(false, $\"{op} needs 'tags' (array) or 'name'.\", inputId, s.Id);\n var changed = 0;\n foreach (var t in tags)\n changed += (op == \"tag\" ? AddTagTo(s.Tags, t, reserved: true) : RemoveTagFrom(s.Tags, t)) ? 1 : 0;\n if (changed > 0) { await SaveAsync(); RefreshSessions(Store.Sessions.Values); }\n var verb = op == \"tag\" ? \"Tagged\" : \"Untagged\";\n return new AgentCommandResult(true, $\"{verb} \\\"{s.DisplayTitle}\\\" ({changed} change{(changed == 1 ? \"\" : \"s\")}).\", inputId, s.Id, Persisted: changed > 0);\n }\n\n case \"handoff\":\n {\n // Return the collection's brain as a compact, sparse handoff bundle (current state +\n // canonical truths + open loops + working notes, each with a source link). No LLM, no\n // key — reads the canonical vault. Optional cmd.name carries the task to orient on.\n if (string.IsNullOrWhiteSpace(cmd.project))\n return new AgentCommandResult(false, \"handoff needs 'project' (the collection name).\");\n var colId = ResolveCollectionIdByName(cmd.project!, cmd.deck);\n if (colId is null) return new AgentCommandResult(false, $\"No collection named \\\"{cmd.project}\\\".\", Project: cmd.project);\n var brain = new BrainService(this);\n if (!brain.PathsForBuilt(colId))\n return new AgentCommandResult(false, $\"No brain built yet for \\\"{cmd.project}\\\" — build it in the app first.\", Project: cmd.project);\n var bundle = brain.GetAgentContext(colId, cmd.name ?? \"\");\n return new AgentCommandResult(true, bundle, Project: cmd.project, Persisted: true);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n // Resolve a collection by the agent-supplied project name: first the deck-scoped id, then a\n // case-insensitive name match across decks. Null if no such collection exists.\n private string? ResolveCollectionIdByName(string name, string? deckRef)\n {\n var deck = ResolveDeckId(deckRef);\n var colId = DeckCollectionId(name, deck);\n if (Store.Collections.ContainsKey(colId)) return colId;\n var match = Store.Collections.Values.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase));\n return match?.Id;\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"handoff\" or \"agentcontext\" or \"brainhandoff\" or \"context\" => \"handoff\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return await RefreshIndexedSessionAsync(s, ExplicitId(cmd), cmd.tool);\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Exact-id/self commands must not act on stale metadata. A long Claude session can keep the same\n // resumable id while new turns are appended far past the original indexed window, so refresh the\n // matched source file before filing/favoriting/naming it.\n private async Task RefreshIndexedSessionAsync(ArchiveSession existing, string? expectedId, string? tool)\n {\n try\n {\n var path = string.IsNullOrWhiteSpace(existing.SourcePath) ? \"\"\n : Path.IsPathRooted(existing.SourcePath) ? existing.SourcePath : Path.Combine(_rootPath, existing.SourcePath);\n if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return existing;\n\n var parsed = await ParseSessionAsync(path, string.IsNullOrWhiteSpace(tool) ? existing.Tool : tool!);\n if (parsed is null) return existing;\n\n var targetId = !string.IsNullOrWhiteSpace(expectedId) ? expectedId! : existing.Id;\n if (!SessionHasIdOrAlias(parsed, targetId) && !string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase))\n return existing;\n\n PreserveAppFields(existing, parsed);\n if (!string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase)) Store.Sessions.Remove(existing.Id);\n Store.Sessions[parsed.Id] = parsed;\n\n var info = new FileInfo(path);\n Store.FileStamps[path] = info.LastWriteTimeUtc.Ticks + \":\" + info.Length;\n return parsed;\n }\n catch\n {\n return existing;\n }\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n // ---- Tagging & filtering -------------------------------------------------------------------\n // Reserved tags are auto-managed (archive = \"lives in the archive\", code = \"has code blocks\") and\n // are hidden from the tag UI/filters so user tags stay clean. Everything else is a user tag.\n public static readonly IReadOnlyCollection ReservedTags =\n new HashSet(StringComparer.OrdinalIgnoreCase) { \"archive\", \"code\" };\n\n public static bool IsReservedTag(string? tag) => !string.IsNullOrWhiteSpace(tag) && ReservedTags.Contains(tag.Trim());\n\n public static string NormalizeTag(string? tag)\n {\n var t = Regex.Replace((tag ?? \"\").Trim(), \"\\\\s+\", \" \");\n return t.Length > 40 ? t[..40].Trim() : t;\n }\n\n // The user-facing tags on a chat (reserved tags removed), sorted, de-duped case-insensitively.\n public static IReadOnlyList UserTags(ArchiveSession session) =>\n session.Tags\n .Where(t => !string.IsNullOrWhiteSpace(t) && !IsReservedTag(t))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public async Task AddChatTagAsync(ArchiveSession session, string tag)\n {\n if (!AddTagTo(session.Tags, tag, reserved: true)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RemoveChatTagAsync(ArchiveSession session, string tag)\n {\n if (!RemoveTagFrom(session.Tags, tag)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task AddCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!AddTagTo(c.Tags, tag, reserved: false)) return false;\n await SaveAsync();\n return true;\n }\n\n public async Task RemoveCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!RemoveTagFrom(c.Tags, tag)) return false;\n await SaveAsync();\n return true;\n }\n\n // All user chat tags with counts (most-used first, then alpha) - drives the chat filter strip.\n public IReadOnlyList AllChatTags() =>\n Store.Sessions.Values\n .Where(s => !s.Archived)\n .SelectMany(UserTags)\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public IReadOnlyList AllCollectionTags() =>\n Store.Collections.Values\n .SelectMany(c => c.Tags.Where(t => !string.IsNullOrWhiteSpace(t)))\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n // Back-compat thin wrapper: text + an ANY/ALL include-tag filter.\n public IReadOnlyList Filter(string query, IReadOnlyCollection? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty()).ToList(), MatchAllIncludes = matchAll });\n\n private static bool SessionHasTag(ArchiveSession s, string tag) =>\n s.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // The compound filter: text search, then restrict to a collection (if set), then keep chats that\n // satisfy the include set (ANY or ALL) and carry NONE of the exclude set.\n public IReadOnlyList FilterChats(ChatFilter f)\n {\n IEnumerable baseSet = string.IsNullOrWhiteSpace(f.Query)\n ? OrderedVisibleSessions(Store.Sessions.Values)\n : Search(f.Query);\n\n if (!string.IsNullOrEmpty(f.CollectionId) && Store.Collections.TryGetValue(f.CollectionId, out var col))\n {\n var ids = new HashSet(col.SessionIds, StringComparer.OrdinalIgnoreCase);\n baseSet = baseSet.Where(s => ids.Contains(s.Id));\n }\n\n return baseSet\n .Where(s => f.IncludeTags.Count == 0\n || (f.MatchAllIncludes\n ? f.IncludeTags.All(t => SessionHasTag(s, t))\n : f.IncludeTags.Any(t => SessionHasTag(s, t))))\n .Where(s => f.ExcludeTags.Count == 0 || !f.ExcludeTags.Any(t => SessionHasTag(s, t)))\n .ToList();\n }\n\n // ---- Collection (project) filtering + demote ordering --------------------------------------\n private static bool CollectionHasTag(ArchiveCollection c, string tag) =>\n c.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // Filter collections by their own tags (compound): keep those satisfying the include set (ANY or\n // ALL) and carrying NONE of the exclude set. \"active + graphics, not web-dev\" -> include both, exclude web-dev.\n public IReadOnlyList FilterCollections(IReadOnlyCollection include, IReadOnlyCollection exclude, bool matchAll = false)\n {\n return Store.Collections.Values\n .Where(c => include.Count == 0\n || (matchAll ? include.All(t => CollectionHasTag(c, t)) : include.Any(t => CollectionHasTag(c, t))))\n .Where(c => exclude.Count == 0 || !exclude.Any(t => CollectionHasTag(c, t)))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n }\n\n // Layer rules: a tag can be assigned a layer (LOWER number = HIGHER in the list). Unassigned tags\n // use DefaultLayer, so layered tags can float above (e.g. active=1) or sink below (e.g. context=900).\n public const int DefaultLayer = 100;\n\n public int TagLayer(string tag) =>\n Store.TagLayers.TryGetValue(TagColorKey(tag), out var n) ? n : DefaultLayer;\n\n // A chat's layer = the strongest (lowest) EXPLICITLY-assigned layer among its tags; DefaultLayer\n // if none of its tags has a layer rule. (Unlayered tags don't pull a context chat back to the middle.)\n public int SessionLayer(ArchiveSession s)\n {\n int? best = null;\n foreach (var t in UserTags(s))\n if (Store.TagLayers.TryGetValue(TagColorKey(t), out var l))\n best = best is null ? l : Math.Min(best.Value, l);\n return best ?? DefaultLayer;\n }\n\n public async Task SetTagLayerAsync(string tag, int? layer)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (layer is null || layer == DefaultLayer) Store.TagLayers.Remove(key);\n else Store.TagLayers[key] = layer.Value;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Order a collection's chats: pinned first, then by layer (active up / context down), then by the\n // collection's MANUAL order (its SessionIds sequence), then recency. So manual drags persist and\n // layer rules group on top of them.\n public IReadOnlyList OrderCollectionChats(ArchiveCollection col, IEnumerable sessions)\n {\n var manual = new Dictionary(StringComparer.OrdinalIgnoreCase);\n for (var i = 0; i < col.SessionIds.Count; i++) manual.TryAdd(col.SessionIds[i], i);\n return sessions\n .OrderByDescending(s => s.Pinned)\n .ThenBy(s => SessionLayer(s))\n .ThenBy(s => manual.TryGetValue(s.Id, out var i) ? i : int.MaxValue)\n .ThenByDescending(s => s.UpdatedAt)\n .ToList();\n }\n\n // Move a chat within a collection's manual order by delta (negative = up). toEnd jumps to top/bottom.\n public async Task ReorderInCollectionAsync(string collectionId, string sessionId, int delta, bool toEnd = false)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n var i = col.SessionIds.FindIndex(x => string.Equals(x, sessionId, StringComparison.OrdinalIgnoreCase));\n if (i < 0) return;\n var target = toEnd ? (delta < 0 ? 0 : col.SessionIds.Count - 1) : Math.Clamp(i + delta, 0, col.SessionIds.Count - 1);\n if (target == i) return;\n col.SessionIds.RemoveAt(i);\n col.SessionIds.Insert(target, sessionId);\n await SaveAsync();\n }\n\n // ---- Tag colors ----------------------------------------------------------------------------\n // A curated palette that reads on pure-black AMOLED and coexists with the rose accent (Tailwind-400\n // family). Tags get a deterministic auto-color from this by name hash; users can override per tag.\n // Rose (#FB7185) is deliberately excluded - it's reserved for the accent / active-filter state.\n public static readonly IReadOnlyList TagPalette = new[]\n {\n \"#60A5FA\", \"#38BDF8\", \"#22D3EE\", \"#2DD4BF\", \"#34D399\",\n \"#A3E635\", \"#FBBF24\", \"#FB923C\", \"#F87171\", \"#A78BFA\"\n };\n\n private static string TagColorKey(string tag) => NormalizeTag(tag).ToLowerInvariant();\n\n // Stable across runs (string.GetHashCode is randomized in .NET, so use FNV-1a).\n private static uint Fnv1a(string s)\n {\n uint hash = 2166136261;\n foreach (var ch in s) { hash ^= ch; hash *= 16777619; }\n return hash;\n }\n\n public static string AutoTagColor(string tag)\n {\n var key = NormalizeTag(tag).ToLowerInvariant();\n if (key.Length == 0) return TagPalette[0];\n return TagPalette[(int)(Fnv1a(key) % (uint)TagPalette.Count)];\n }\n\n // The effective color for a tag: a user override if set, else the deterministic auto-color.\n public string TagColor(string tag)\n {\n var key = TagColorKey(tag);\n return Store.TagColors.TryGetValue(key, out var hex) && !string.IsNullOrWhiteSpace(hex)\n ? hex\n : AutoTagColor(tag);\n }\n\n public bool HasCustomTagColor(string tag) => Store.TagColors.ContainsKey(TagColorKey(tag));\n\n // Set (or clear, when hex is null/blank) a tag's color override; clearing returns it to auto.\n public async Task SetTagColorAsync(string tag, string? hex)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (string.IsNullOrWhiteSpace(hex)) Store.TagColors.Remove(key);\n else Store.TagColors[key] = hex.Trim();\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Generic add/remove against either tag store (ObservableCollection for chats, List for collections).\n private static bool AddTagTo(ICollection store, string tag, bool reserved)\n {\n var t = NormalizeTag(tag);\n if (t.Length == 0 || (reserved && IsReservedTag(t))) return false;\n if (store.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) return false;\n store.Add(t);\n return true;\n }\n\n private static bool RemoveTagFrom(ICollection store, string tag)\n {\n var match = store.FirstOrDefault(x => string.Equals(x, (tag ?? \"\").Trim(), StringComparison.OrdinalIgnoreCase));\n if (match is null) return false;\n store.Remove(match);\n return true;\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n // Auto-detect per file so a root holding Claude transcripts (or a mix) is routed correctly\n // instead of being force-parsed as Codex — otherwise a Claude .jsonl yields zero messages and\n // is mis-tagged tool=codex (which then breaks ParseFullAsync / the brain source layer).\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"auto\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // The shared, FULL-history result of parsing one transcript, BEFORE the recent-message window is\n // applied. The normal indexing path (ParseJsonlAsync/ParseClaudeSessionAsync) wraps this and trims\n // to the window; the brain wants the untrimmed list for stable anchoring. ONE source of truth for\n // message extraction — a drift-guard test asserts the windowed tail equals this list's tail.\n private sealed record ParsedTranscript(\n ObservableCollection Messages,\n ObservableCollection CodeBlocks,\n string Id, string Title, string Created, string Updated,\n string Cwd, HashSet Aliases, int Total, string Tool);\n\n // Brain source layer: re-parse a session's raw transcript WITHOUT the 600-message window, yielding\n // the full ordered message list with a stable per-message Index (0-based over the whole transcript)\n // — the basis for source blocks and anchors. Reuses the exact same extraction core as indexing.\n public async Task> ParseFullAsync(ArchiveSession session)\n {\n if (session is null || string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath))\n return Array.Empty();\n var tool = string.IsNullOrWhiteSpace(session.Tool) || session.Tool == \"auto\"\n ? DetectTool(session.SourcePath) : session.Tool;\n var core = tool == \"claude\"\n ? await ParseClaudeCoreAsync(session.SourcePath)\n : await ParseCodexCoreAsync(session.SourcePath);\n if (core is null) return Array.Empty();\n var list = new List(core.Messages.Count);\n for (var i = 0; i < core.Messages.Count; i++)\n {\n var m = core.Messages[i];\n list.Add(new FullMessage(i, m.Role, m.Text, m.Timestamp, m.EffectiveKind == \"tool\" ? m.ToolName : null));\n }\n return list;\n }\n\n // Brain \"open source span\": the messages of a transcript in the inclusive index range a card's\n // anchor points at (optionally with N messages of context on each side). Indices are over the FULL\n // history, matching the anchors SourceBlocker produced. Clamped, so a stale anchor never throws.\n public async Task> ParseFullRangeAsync(ArchiveSession session, int startIndex, int endIndex, int context = 0)\n {\n var full = await ParseFullAsync(session);\n if (full.Count == 0) return full;\n var lo = Math.Max(0, Math.Min(startIndex, endIndex) - Math.Max(0, context));\n var hi = Math.Min(full.Count - 1, Math.Max(startIndex, endIndex) + Math.Max(0, context));\n var slice = new List(Math.Max(0, hi - lo + 1));\n for (var i = lo; i <= hi; i++) slice.Add(full[i]);\n return slice;\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null, string? extraArgsOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n // Claude resume is directory-scoped (it only finds the session under the launch dir's encoded\n // project folder), so it needs the recovered launch dir — not the recorded workspace subdir.\n var cwd = isClaude ? ResolveClaudeResumeDirectory(session) : ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var baseArgs = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n // Optional user-configured launch args (e.g. \"--profile http_sse\" for Codex) go in the PREFIX\n // position — right after the exe, before the subcommand — so global flags apply (Codex requires\n // --profile before `resume`). Empty leaves the default command exactly as it was. Newlines are\n // stripped so a stray paste can't break the cmd line; this is the user's own local config.\n var extra = (extraArgsOverride ?? (isClaude ? Store.Settings.ClaudeLaunchArgs : Store.Settings.CodexLaunchArgs) ?? \"\")\n .Replace(\"\\r\", \" \").Replace(\"\\n\", \" \").Trim();\n var args = string.IsNullOrEmpty(extra) ? baseArgs : $\"{extra} {baseArgs}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n // Claude Code files a transcript under a project folder named by DASH-ENCODING the directory that\n // `claude` was launched from (each \\ / : . and whitespace becomes '-'). `claude --resume `\n // only finds the session when run from THAT directory. The cwd recorded inside the transcript can\n // be a SUBDIR of the launch dir (e.g. launched in …\\301, cwd later moved to …\\301\\graphics), so\n // resuming from the recorded workspace fails with \"No conversation found with session ID\". Recover\n // the real launch dir by climbing from the workspace until a directory's encoding matches the\n // transcript's project-folder name. Falls back to the recorded workspace if nothing matches.\n private static string ResolveClaudeResumeDirectory(ArchiveSession session)\n {\n var fallback = ResolveWorkingDirectory(session);\n var projectFolder = Path.GetFileName(Path.GetDirectoryName(session.SourcePath) ?? \"\");\n if (string.IsNullOrWhiteSpace(projectFolder)) return fallback;\n\n foreach (var start in new[] { session.Workspace, fallback })\n {\n var dir = string.IsNullOrWhiteSpace(start) ? null : start;\n for (var guard = 0; guard < 64 && !string.IsNullOrWhiteSpace(dir); guard++)\n {\n if (string.Equals(EncodeClaudeProjectFolder(dir!), projectFolder, StringComparison.OrdinalIgnoreCase)\n && Directory.Exists(dir!))\n return dir!;\n dir = Path.GetDirectoryName(dir!.TrimEnd('\\\\', '/'));\n }\n }\n return fallback;\n }\n\n // Mirror Claude Code's project-folder encoding: every path separator, drive colon, dot, or\n // whitespace char becomes '-', preserving case and any literal dashes already in the path.\n private static string EncodeClaudeProjectFolder(string path) =>\n Regex.Replace(path.TrimEnd('\\\\', '/'), @\"[\\\\/:.\\s]\", \"-\");\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return ElementText(v);\n }\n\n private static string ElementText(JsonElement v) =>\n v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n\n private static string ElementString(JsonElement v) =>\n v.ValueKind == JsonValueKind.String ? v.GetString() ?? \"\" : \"\";\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseCodexCoreAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? ElementString(rtProp) : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && ElementString(sidProp) is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && ElementString(midProp) is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && ElementString(forkProp) is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp) && ElementString(cwdProp) is { Length: > 0 } cw) cwd = cw;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? ElementString(typeProp) : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = Field(payload, \"message\");\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = Field(payload, \"name\");\n if (string.IsNullOrWhiteSpace(name)) name = \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = Field(payload, \"output\");\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = Field(payload, \"role\");\n if (string.IsNullOrWhiteSpace(role)) role = \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ParsedTranscript(messages, codeBlocks, id, title, created, updated, cwd, aliases, messages.Count, \"codex\");\n }\n\n // Thin wrapper: full Codex parse, then trim to the recent-message window for the in-app reader.\n private async Task ParseJsonlAsync(string filePath)\n {\n var p = await ParseCodexCoreAsync(filePath);\n var (messages, codeBlocks) = KeepRecentWindow(p.Messages, p.CodeBlocks);\n return new ArchiveSession\n {\n Id = p.Id,\n Title = p.Title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(p.Aliases.Where(a => !string.Equals(a, p.Id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = p.Created,\n UpdatedAt = p.Updated,\n Workspace = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown workspace\" : p.Cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown\" : Path.GetFileName(p.Cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = p.Total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeCoreAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n return new ParsedTranscript(messages, codeBlocks, id, title, created, updated, cwd,\n new HashSet(StringComparer.OrdinalIgnoreCase), messages.Count, \"claude\");\n }\n\n // Thin wrapper: full Claude parse (null = sidechain/subagent file), then trim to the reader window.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var p = await ParseClaudeCoreAsync(filePath);\n if (p is null) return null;\n var (messages, codeBlocks) = KeepRecentWindow(p.Messages, p.CodeBlocks);\n return new ArchiveSession\n {\n Id = p.Id,\n Title = p.Title,\n SourcePath = filePath,\n CreatedAt = p.Created,\n UpdatedAt = p.Updated,\n Workspace = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown workspace\" : p.Cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown\" : Path.GetFileName(p.Cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = p.Total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = ElementText(argsProp);\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "b5f802d5ce1170d7bf3a5fbe521170aa0efa098f": {"t": 1785756766.2912266, "n": 137138, "h": "3eee411207dbc664", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Memory;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 10; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n EnsureDecks();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n try\n {\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n }\n catch (Exception ex)\n {\n session.Messages = new ObservableCollection\n {\n new()\n {\n Id = \"load-error\",\n Role = \"assistant\",\n Timestamp = session.UpdatedAt,\n Text = $\"Could not load the full transcript from disk.\\n\\n{ex.Message}\\n\\nSource: {path}\"\n }\n };\n session.CodeBlocks = new ObservableCollection();\n session.MessageCount = session.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n Decks = new List(Store.Decks),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n TagColors = new Dictionary(Store.TagColors),\n TagLayers = new Dictionary(Store.TagLayers),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? MainDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n }\n else if (string.IsNullOrWhiteSpace(collection.DeckId)) collection.DeckId = deck;\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? ActiveDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n // Add a chat to an EXISTING collection by its id (used by the \"Add to collection\" menus, which\n // already know the collection - avoids any name/deck ambiguity).\n public async Task AddToCollectionByIdAsync(ArchiveSession session, string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n if (!col.SessionIds.Contains(session.Id)) col.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // ---- Decks (top-level groupings of collections) --------------------------------------------\n public const string MainDeckId = \"main\";\n\n public void EnsureDecks()\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, MainDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Decks.Insert(0, new Deck { Id = MainDeckId, Name = \"Main\" });\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId)\n || !Store.Decks.Any(d => string.Equals(d.Id, Store.Settings.ActiveDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.ActiveDeckId = MainDeckId;\n }\n\n public IReadOnlyList Decks => Store.Decks;\n public string ActiveDeckId => string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId) ? MainDeckId : Store.Settings.ActiveDeckId;\n public static string CollectionDeck(ArchiveCollection c) => string.IsNullOrWhiteSpace(c.DeckId) ? MainDeckId : c.DeckId;\n\n public IReadOnlyList CollectionsInDeck(string deckId) =>\n Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)).ToList();\n\n public int CollectionCountInDeck(string deckId) =>\n Store.Collections.Values.Count(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase));\n\n // Resolve a deck reference (id OR name, case-insensitive) to a deck id; null/unknown -> main.\n public string ResolveDeckId(string? deckRef)\n {\n if (string.IsNullOrWhiteSpace(deckRef)) return MainDeckId;\n var d = Store.Decks.FirstOrDefault(x =>\n string.Equals(x.Id, deckRef, StringComparison.OrdinalIgnoreCase) ||\n string.Equals(x.Name, deckRef, StringComparison.OrdinalIgnoreCase));\n return d?.Id ?? MainDeckId;\n }\n\n private string DeckCollectionId(string name, string deckId) =>\n string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase) ? Slug(name) : $\"{Slug(deckId)}--{Slug(name)}\";\n\n public async Task CreateDeckAsync(string name)\n {\n var id = UniqueDeckId(name);\n var deck = new Deck { Id = id, Name = name.Trim(), CreatedAt = DateTime.UtcNow.ToString(\"O\") };\n Store.Decks.Add(deck);\n await SaveAsync();\n return deck;\n }\n\n private string UniqueDeckId(string name)\n {\n var baseId = Slug(name);\n if (string.Equals(baseId, MainDeckId, StringComparison.OrdinalIgnoreCase)) baseId = \"deck-\" + baseId;\n var id = baseId;\n var n = 2;\n while (Store.Decks.Any(d => string.Equals(d.Id, id, StringComparison.OrdinalIgnoreCase))) id = $\"{baseId}-{n++}\";\n return id;\n }\n\n public async Task RenameDeckAsync(string deckId, string name)\n {\n var d = Store.Decks.FirstOrDefault(x => string.Equals(x.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (d is null || string.IsNullOrWhiteSpace(name)) return;\n d.Name = name.Trim();\n await SaveAsync();\n }\n\n // Delete a deck (never Main) - its collections move to Main so nothing is orphaned.\n public async Task DeleteDeckAsync(string deckId)\n {\n if (string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase)) return;\n foreach (var c in Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)))\n c.DeckId = MainDeckId;\n Store.Decks.RemoveAll(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (string.Equals(Store.Settings.ActiveDeckId, deckId, StringComparison.OrdinalIgnoreCase))\n Store.Settings.ActiveDeckId = MainDeckId;\n await SaveAsync();\n }\n\n public async Task SetActiveDeckAsync(string deckId)\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n Store.Settings.ActiveDeckId = deckId;\n await SaveAsync();\n }\n\n public async Task MoveCollectionToDeckAsync(string collectionId, string deckId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return;\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n c.DeckId = ResolveDeckId(deckId);\n await SaveAsync();\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Per-collection Memory Bank / Project Brain vaults live here, a sibling of the backups dir.\n public string BrainsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"brains\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n var deck = ResolveDeckId(cmd.deck);\n await AddToCollectionAsync(s, cmd.project!, deck);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var colId = DeckCollectionId(cmd.project!, deck);\n var persisted = Store.Collections.TryGetValue(colId, out var collection)\n && collection.SessionIds.Contains(s.Id);\n var deckName = Store.Decks.FirstOrDefault(d => string.Equals(d.Id, deck, StringComparison.OrdinalIgnoreCase))?.Name ?? \"Main\";\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\" on deck \\\"{deckName}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n case \"tag\":\n case \"untag\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(op), inputId);\n var tags = (cmd.tags ?? new List()).ToList();\n if (!string.IsNullOrWhiteSpace(cmd.name)) tags.Add(cmd.name!);\n tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();\n if (tags.Count == 0) return new AgentCommandResult(false, $\"{op} needs 'tags' (array) or 'name'.\", inputId, s.Id);\n var changed = 0;\n foreach (var t in tags)\n changed += (op == \"tag\" ? AddTagTo(s.Tags, t, reserved: true) : RemoveTagFrom(s.Tags, t)) ? 1 : 0;\n if (changed > 0) { await SaveAsync(); RefreshSessions(Store.Sessions.Values); }\n var verb = op == \"tag\" ? \"Tagged\" : \"Untagged\";\n return new AgentCommandResult(true, $\"{verb} \\\"{s.DisplayTitle}\\\" ({changed} change{(changed == 1 ? \"\" : \"s\")}).\", inputId, s.Id, Persisted: changed > 0);\n }\n\n case \"handoff\":\n {\n // Return the collection's brain as a compact, sparse handoff bundle (current state +\n // canonical truths + open loops + working notes, each with a source link). No LLM, no\n // key — reads the canonical vault. Optional cmd.name carries the task to orient on.\n if (string.IsNullOrWhiteSpace(cmd.project))\n return new AgentCommandResult(false, \"handoff needs 'project' (the collection name).\");\n var colId = ResolveCollectionIdByName(cmd.project!, cmd.deck);\n if (colId is null) return new AgentCommandResult(false, $\"No collection named \\\"{cmd.project}\\\".\", Project: cmd.project);\n var brain = new BrainService(this);\n if (!brain.PathsForBuilt(colId))\n return new AgentCommandResult(false, $\"No brain built yet for \\\"{cmd.project}\\\" — build it in the app first.\", Project: cmd.project);\n var bundle = brain.GetAgentContext(colId, cmd.name ?? \"\");\n return new AgentCommandResult(true, bundle, Project: cmd.project, Persisted: true);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n // Resolve a collection by the agent-supplied project name: first the deck-scoped id, then a\n // case-insensitive name match across decks. Null if no such collection exists.\n private string? ResolveCollectionIdByName(string name, string? deckRef)\n {\n var deck = ResolveDeckId(deckRef);\n var colId = DeckCollectionId(name, deck);\n if (Store.Collections.ContainsKey(colId)) return colId;\n var match = Store.Collections.Values.FirstOrDefault(c => string.Equals(c.Name, name, StringComparison.OrdinalIgnoreCase));\n return match?.Id;\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"handoff\" or \"agentcontext\" or \"brainhandoff\" or \"context\" => \"handoff\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return await RefreshIndexedSessionAsync(s, ExplicitId(cmd), cmd.tool);\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Exact-id/self commands must not act on stale metadata. A long Claude session can keep the same\n // resumable id while new turns are appended far past the original indexed window, so refresh the\n // matched source file before filing/favoriting/naming it.\n private async Task RefreshIndexedSessionAsync(ArchiveSession existing, string? expectedId, string? tool)\n {\n try\n {\n var path = string.IsNullOrWhiteSpace(existing.SourcePath) ? \"\"\n : Path.IsPathRooted(existing.SourcePath) ? existing.SourcePath : Path.Combine(_rootPath, existing.SourcePath);\n if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return existing;\n\n var parsed = await ParseSessionAsync(path, string.IsNullOrWhiteSpace(tool) ? existing.Tool : tool!);\n if (parsed is null) return existing;\n\n var targetId = !string.IsNullOrWhiteSpace(expectedId) ? expectedId! : existing.Id;\n if (!SessionHasIdOrAlias(parsed, targetId) && !string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase))\n return existing;\n\n PreserveAppFields(existing, parsed);\n if (!string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase)) Store.Sessions.Remove(existing.Id);\n Store.Sessions[parsed.Id] = parsed;\n\n var info = new FileInfo(path);\n Store.FileStamps[path] = info.LastWriteTimeUtc.Ticks + \":\" + info.Length;\n return parsed;\n }\n catch\n {\n return existing;\n }\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n // ---- Tagging & filtering -------------------------------------------------------------------\n // Reserved tags are auto-managed (archive = \"lives in the archive\", code = \"has code blocks\") and\n // are hidden from the tag UI/filters so user tags stay clean. Everything else is a user tag.\n public static readonly IReadOnlyCollection ReservedTags =\n new HashSet(StringComparer.OrdinalIgnoreCase) { \"archive\", \"code\" };\n\n public static bool IsReservedTag(string? tag) => !string.IsNullOrWhiteSpace(tag) && ReservedTags.Contains(tag.Trim());\n\n public static string NormalizeTag(string? tag)\n {\n var t = Regex.Replace((tag ?? \"\").Trim(), \"\\\\s+\", \" \");\n return t.Length > 40 ? t[..40].Trim() : t;\n }\n\n // The user-facing tags on a chat (reserved tags removed), sorted, de-duped case-insensitively.\n public static IReadOnlyList UserTags(ArchiveSession session) =>\n session.Tags\n .Where(t => !string.IsNullOrWhiteSpace(t) && !IsReservedTag(t))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public async Task AddChatTagAsync(ArchiveSession session, string tag)\n {\n if (!AddTagTo(session.Tags, tag, reserved: true)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RemoveChatTagAsync(ArchiveSession session, string tag)\n {\n if (!RemoveTagFrom(session.Tags, tag)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task AddCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!AddTagTo(c.Tags, tag, reserved: false)) return false;\n await SaveAsync();\n return true;\n }\n\n public async Task RemoveCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!RemoveTagFrom(c.Tags, tag)) return false;\n await SaveAsync();\n return true;\n }\n\n // All user chat tags with counts (most-used first, then alpha) - drives the chat filter strip.\n public IReadOnlyList AllChatTags() =>\n Store.Sessions.Values\n .Where(s => !s.Archived)\n .SelectMany(UserTags)\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public IReadOnlyList AllCollectionTags() =>\n Store.Collections.Values\n .SelectMany(c => c.Tags.Where(t => !string.IsNullOrWhiteSpace(t)))\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n // Back-compat thin wrapper: text + an ANY/ALL include-tag filter.\n public IReadOnlyList Filter(string query, IReadOnlyCollection? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty()).ToList(), MatchAllIncludes = matchAll });\n\n private static bool SessionHasTag(ArchiveSession s, string tag) =>\n s.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // The compound filter: text search, then restrict to a collection (if set), then keep chats that\n // satisfy the include set (ANY or ALL) and carry NONE of the exclude set.\n public IReadOnlyList FilterChats(ChatFilter f)\n {\n IEnumerable baseSet = string.IsNullOrWhiteSpace(f.Query)\n ? OrderedVisibleSessions(Store.Sessions.Values)\n : Search(f.Query);\n\n if (!string.IsNullOrEmpty(f.CollectionId) && Store.Collections.TryGetValue(f.CollectionId, out var col))\n {\n var ids = new HashSet(col.SessionIds, StringComparer.OrdinalIgnoreCase);\n baseSet = baseSet.Where(s => ids.Contains(s.Id));\n }\n\n return baseSet\n .Where(s => f.IncludeTags.Count == 0\n || (f.MatchAllIncludes\n ? f.IncludeTags.All(t => SessionHasTag(s, t))\n : f.IncludeTags.Any(t => SessionHasTag(s, t))))\n .Where(s => f.ExcludeTags.Count == 0 || !f.ExcludeTags.Any(t => SessionHasTag(s, t)))\n .ToList();\n }\n\n // ---- Collection (project) filtering + demote ordering --------------------------------------\n private static bool CollectionHasTag(ArchiveCollection c, string tag) =>\n c.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // Filter collections by their own tags (compound): keep those satisfying the include set (ANY or\n // ALL) and carrying NONE of the exclude set. \"active + graphics, not web-dev\" -> include both, exclude web-dev.\n public IReadOnlyList FilterCollections(IReadOnlyCollection include, IReadOnlyCollection exclude, bool matchAll = false)\n {\n return Store.Collections.Values\n .Where(c => include.Count == 0\n || (matchAll ? include.All(t => CollectionHasTag(c, t)) : include.Any(t => CollectionHasTag(c, t))))\n .Where(c => exclude.Count == 0 || !exclude.Any(t => CollectionHasTag(c, t)))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n }\n\n // Layer rules: a tag can be assigned a layer (LOWER number = HIGHER in the list). Unassigned tags\n // use DefaultLayer, so layered tags can float above (e.g. active=1) or sink below (e.g. context=900).\n public const int DefaultLayer = 100;\n\n public int TagLayer(string tag) =>\n Store.TagLayers.TryGetValue(TagColorKey(tag), out var n) ? n : DefaultLayer;\n\n // A chat's layer = the strongest (lowest) EXPLICITLY-assigned layer among its tags; DefaultLayer\n // if none of its tags has a layer rule. (Unlayered tags don't pull a context chat back to the middle.)\n public int SessionLayer(ArchiveSession s)\n {\n int? best = null;\n foreach (var t in UserTags(s))\n if (Store.TagLayers.TryGetValue(TagColorKey(t), out var l))\n best = best is null ? l : Math.Min(best.Value, l);\n return best ?? DefaultLayer;\n }\n\n public async Task SetTagLayerAsync(string tag, int? layer)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (layer is null || layer == DefaultLayer) Store.TagLayers.Remove(key);\n else Store.TagLayers[key] = layer.Value;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Order a collection's chats: pinned first, then by layer (active up / context down), then by the\n // collection's MANUAL order (its SessionIds sequence), then recency. So manual drags persist and\n // layer rules group on top of them.\n public IReadOnlyList OrderCollectionChats(ArchiveCollection col, IEnumerable sessions)\n {\n var manual = new Dictionary(StringComparer.OrdinalIgnoreCase);\n for (var i = 0; i < col.SessionIds.Count; i++) manual.TryAdd(col.SessionIds[i], i);\n return sessions\n .OrderByDescending(s => s.Pinned)\n .ThenBy(s => SessionLayer(s))\n .ThenBy(s => manual.TryGetValue(s.Id, out var i) ? i : int.MaxValue)\n .ThenByDescending(s => s.UpdatedAt)\n .ToList();\n }\n\n // Move a chat within a collection's manual order by delta (negative = up). toEnd jumps to top/bottom.\n public async Task ReorderInCollectionAsync(string collectionId, string sessionId, int delta, bool toEnd = false)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n var i = col.SessionIds.FindIndex(x => string.Equals(x, sessionId, StringComparison.OrdinalIgnoreCase));\n if (i < 0) return;\n var target = toEnd ? (delta < 0 ? 0 : col.SessionIds.Count - 1) : Math.Clamp(i + delta, 0, col.SessionIds.Count - 1);\n if (target == i) return;\n col.SessionIds.RemoveAt(i);\n col.SessionIds.Insert(target, sessionId);\n await SaveAsync();\n }\n\n // ---- Tag colors ----------------------------------------------------------------------------\n // A curated palette that reads on pure-black AMOLED and coexists with the rose accent (Tailwind-400\n // family). Tags get a deterministic auto-color from this by name hash; users can override per tag.\n // Rose (#FB7185) is deliberately excluded - it's reserved for the accent / active-filter state.\n public static readonly IReadOnlyList TagPalette = new[]\n {\n \"#60A5FA\", \"#38BDF8\", \"#22D3EE\", \"#2DD4BF\", \"#34D399\",\n \"#A3E635\", \"#FBBF24\", \"#FB923C\", \"#F87171\", \"#A78BFA\"\n };\n\n private static string TagColorKey(string tag) => NormalizeTag(tag).ToLowerInvariant();\n\n // Stable across runs (string.GetHashCode is randomized in .NET, so use FNV-1a).\n private static uint Fnv1a(string s)\n {\n uint hash = 2166136261;\n foreach (var ch in s) { hash ^= ch; hash *= 16777619; }\n return hash;\n }\n\n public static string AutoTagColor(string tag)\n {\n var key = NormalizeTag(tag).ToLowerInvariant();\n if (key.Length == 0) return TagPalette[0];\n return TagPalette[(int)(Fnv1a(key) % (uint)TagPalette.Count)];\n }\n\n // The effective color for a tag: a user override if set, else the deterministic auto-color.\n public string TagColor(string tag)\n {\n var key = TagColorKey(tag);\n return Store.TagColors.TryGetValue(key, out var hex) && !string.IsNullOrWhiteSpace(hex)\n ? hex\n : AutoTagColor(tag);\n }\n\n public bool HasCustomTagColor(string tag) => Store.TagColors.ContainsKey(TagColorKey(tag));\n\n // Set (or clear, when hex is null/blank) a tag's color override; clearing returns it to auto.\n public async Task SetTagColorAsync(string tag, string? hex)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (string.IsNullOrWhiteSpace(hex)) Store.TagColors.Remove(key);\n else Store.TagColors[key] = hex.Trim();\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Generic add/remove against either tag store (ObservableCollection for chats, List for collections).\n private static bool AddTagTo(ICollection store, string tag, bool reserved)\n {\n var t = NormalizeTag(tag);\n if (t.Length == 0 || (reserved && IsReservedTag(t))) return false;\n if (store.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) return false;\n store.Add(t);\n return true;\n }\n\n private static bool RemoveTagFrom(ICollection store, string tag)\n {\n var match = store.FirstOrDefault(x => string.Equals(x, (tag ?? \"\").Trim(), StringComparison.OrdinalIgnoreCase));\n if (match is null) return false;\n store.Remove(match);\n return true;\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n // Auto-detect per file so a root holding Claude transcripts (or a mix) is routed correctly\n // instead of being force-parsed as Codex — otherwise a Claude .jsonl yields zero messages and\n // is mis-tagged tool=codex (which then breaks ParseFullAsync / the brain source layer).\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"auto\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // The shared, FULL-history result of parsing one transcript, BEFORE the recent-message window is\n // applied. The normal indexing path (ParseJsonlAsync/ParseClaudeSessionAsync) wraps this and trims\n // to the window; the brain wants the untrimmed list for stable anchoring. ONE source of truth for\n // message extraction — a drift-guard test asserts the windowed tail equals this list's tail.\n private sealed record ParsedTranscript(\n ObservableCollection Messages,\n ObservableCollection CodeBlocks,\n string Id, string Title, string Created, string Updated,\n string Cwd, HashSet Aliases, int Total, string Tool);\n\n // Brain source layer: re-parse a session's raw transcript WITHOUT the 600-message window, yielding\n // the full ordered message list with a stable per-message Index (0-based over the whole transcript)\n // — the basis for source blocks and anchors. Reuses the exact same extraction core as indexing.\n public async Task> ParseFullAsync(ArchiveSession session)\n {\n if (session is null || string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath))\n return Array.Empty();\n var tool = string.IsNullOrWhiteSpace(session.Tool) || session.Tool == \"auto\"\n ? DetectTool(session.SourcePath) : session.Tool;\n var core = tool == \"claude\"\n ? await ParseClaudeCoreAsync(session.SourcePath)\n : await ParseCodexCoreAsync(session.SourcePath);\n if (core is null) return Array.Empty();\n var list = new List(core.Messages.Count);\n for (var i = 0; i < core.Messages.Count; i++)\n {\n var m = core.Messages[i];\n list.Add(new FullMessage(i, m.Role, m.Text, m.Timestamp, m.EffectiveKind == \"tool\" ? m.ToolName : null));\n }\n return list;\n }\n\n // Brain \"open source span\": the messages of a transcript in the inclusive index range a card's\n // anchor points at (optionally with N messages of context on each side). Indices are over the FULL\n // history, matching the anchors SourceBlocker produced. Clamped, so a stale anchor never throws.\n public async Task> ParseFullRangeAsync(ArchiveSession session, int startIndex, int endIndex, int context = 0)\n {\n var full = await ParseFullAsync(session);\n if (full.Count == 0) return full;\n var lo = Math.Max(0, Math.Min(startIndex, endIndex) - Math.Max(0, context));\n var hi = Math.Min(full.Count - 1, Math.Max(startIndex, endIndex) + Math.Max(0, context));\n var slice = new List(Math.Max(0, hi - lo + 1));\n for (var i = lo; i <= hi; i++) slice.Add(full[i]);\n return slice;\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n // Claude resume is directory-scoped (it only finds the session under the launch dir's encoded\n // project folder), so it needs the recovered launch dir — not the recorded workspace subdir.\n var cwd = isClaude ? ResolveClaudeResumeDirectory(session) : ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n // Claude Code files a transcript under a project folder named by DASH-ENCODING the directory that\n // `claude` was launched from (each \\ / : . and whitespace becomes '-'). `claude --resume `\n // only finds the session when run from THAT directory. The cwd recorded inside the transcript can\n // be a SUBDIR of the launch dir (e.g. launched in …\\301, cwd later moved to …\\301\\graphics), so\n // resuming from the recorded workspace fails with \"No conversation found with session ID\". Recover\n // the real launch dir by climbing from the workspace until a directory's encoding matches the\n // transcript's project-folder name. Falls back to the recorded workspace if nothing matches.\n private static string ResolveClaudeResumeDirectory(ArchiveSession session)\n {\n var fallback = ResolveWorkingDirectory(session);\n var projectFolder = Path.GetFileName(Path.GetDirectoryName(session.SourcePath) ?? \"\");\n if (string.IsNullOrWhiteSpace(projectFolder)) return fallback;\n\n foreach (var start in new[] { session.Workspace, fallback })\n {\n var dir = string.IsNullOrWhiteSpace(start) ? null : start;\n for (var guard = 0; guard < 64 && !string.IsNullOrWhiteSpace(dir); guard++)\n {\n if (string.Equals(EncodeClaudeProjectFolder(dir!), projectFolder, StringComparison.OrdinalIgnoreCase)\n && Directory.Exists(dir!))\n return dir!;\n dir = Path.GetDirectoryName(dir!.TrimEnd('\\\\', '/'));\n }\n }\n return fallback;\n }\n\n // Mirror Claude Code's project-folder encoding: every path separator, drive colon, dot, or\n // whitespace char becomes '-', preserving case and any literal dashes already in the path.\n private static string EncodeClaudeProjectFolder(string path) =>\n Regex.Replace(path.TrimEnd('\\\\', '/'), @\"[\\\\/:.\\s]\", \"-\");\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return ElementText(v);\n }\n\n private static string ElementText(JsonElement v) =>\n v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n\n private static string ElementString(JsonElement v) =>\n v.ValueKind == JsonValueKind.String ? v.GetString() ?? \"\" : \"\";\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseCodexCoreAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? ElementString(rtProp) : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && ElementString(sidProp) is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && ElementString(midProp) is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && ElementString(forkProp) is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp) && ElementString(cwdProp) is { Length: > 0 } cw) cwd = cw;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? ElementString(typeProp) : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = Field(payload, \"message\");\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = Field(payload, \"name\");\n if (string.IsNullOrWhiteSpace(name)) name = \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = Field(payload, \"output\");\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = Field(payload, \"role\");\n if (string.IsNullOrWhiteSpace(role)) role = \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ParsedTranscript(messages, codeBlocks, id, title, created, updated, cwd, aliases, messages.Count, \"codex\");\n }\n\n // Thin wrapper: full Codex parse, then trim to the recent-message window for the in-app reader.\n private async Task ParseJsonlAsync(string filePath)\n {\n var p = await ParseCodexCoreAsync(filePath);\n var (messages, codeBlocks) = KeepRecentWindow(p.Messages, p.CodeBlocks);\n return new ArchiveSession\n {\n Id = p.Id,\n Title = p.Title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(p.Aliases.Where(a => !string.Equals(a, p.Id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = p.Created,\n UpdatedAt = p.Updated,\n Workspace = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown workspace\" : p.Cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown\" : Path.GetFileName(p.Cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = p.Total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeCoreAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n return new ParsedTranscript(messages, codeBlocks, id, title, created, updated, cwd,\n new HashSet(StringComparer.OrdinalIgnoreCase), messages.Count, \"claude\");\n }\n\n // Thin wrapper: full Claude parse (null = sidechain/subagent file), then trim to the reader window.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var p = await ParseClaudeCoreAsync(filePath);\n if (p is null) return null;\n var (messages, codeBlocks) = KeepRecentWindow(p.Messages, p.CodeBlocks);\n return new ArchiveSession\n {\n Id = p.Id,\n Title = p.Title,\n SourcePath = filePath,\n CreatedAt = p.Created,\n UpdatedAt = p.Updated,\n Workspace = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown workspace\" : p.Cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown\" : Path.GetFileName(p.Cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = p.Total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = ElementText(argsProp);\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "8fb80ebdcbb4ef525f9fad9ceee4a1c2cd8ec823": {"t": 1785756768.4041057, "n": 134406, "h": "c353a862493763a2", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Memory;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 10; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n EnsureDecks();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n try\n {\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n }\n catch (Exception ex)\n {\n session.Messages = new ObservableCollection\n {\n new()\n {\n Id = \"load-error\",\n Role = \"assistant\",\n Timestamp = session.UpdatedAt,\n Text = $\"Could not load the full transcript from disk.\\n\\n{ex.Message}\\n\\nSource: {path}\"\n }\n };\n session.CodeBlocks = new ObservableCollection();\n session.MessageCount = session.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n Decks = new List(Store.Decks),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n TagColors = new Dictionary(Store.TagColors),\n TagLayers = new Dictionary(Store.TagLayers),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? MainDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n }\n else if (string.IsNullOrWhiteSpace(collection.DeckId)) collection.DeckId = deck;\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? ActiveDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n // Add a chat to an EXISTING collection by its id (used by the \"Add to collection\" menus, which\n // already know the collection - avoids any name/deck ambiguity).\n public async Task AddToCollectionByIdAsync(ArchiveSession session, string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n if (!col.SessionIds.Contains(session.Id)) col.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // ---- Decks (top-level groupings of collections) --------------------------------------------\n public const string MainDeckId = \"main\";\n\n public void EnsureDecks()\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, MainDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Decks.Insert(0, new Deck { Id = MainDeckId, Name = \"Main\" });\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId)\n || !Store.Decks.Any(d => string.Equals(d.Id, Store.Settings.ActiveDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.ActiveDeckId = MainDeckId;\n }\n\n public IReadOnlyList Decks => Store.Decks;\n public string ActiveDeckId => string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId) ? MainDeckId : Store.Settings.ActiveDeckId;\n public static string CollectionDeck(ArchiveCollection c) => string.IsNullOrWhiteSpace(c.DeckId) ? MainDeckId : c.DeckId;\n\n public IReadOnlyList CollectionsInDeck(string deckId) =>\n Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)).ToList();\n\n public int CollectionCountInDeck(string deckId) =>\n Store.Collections.Values.Count(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase));\n\n // Resolve a deck reference (id OR name, case-insensitive) to a deck id; null/unknown -> main.\n public string ResolveDeckId(string? deckRef)\n {\n if (string.IsNullOrWhiteSpace(deckRef)) return MainDeckId;\n var d = Store.Decks.FirstOrDefault(x =>\n string.Equals(x.Id, deckRef, StringComparison.OrdinalIgnoreCase) ||\n string.Equals(x.Name, deckRef, StringComparison.OrdinalIgnoreCase));\n return d?.Id ?? MainDeckId;\n }\n\n private string DeckCollectionId(string name, string deckId) =>\n string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase) ? Slug(name) : $\"{Slug(deckId)}--{Slug(name)}\";\n\n public async Task CreateDeckAsync(string name)\n {\n var id = UniqueDeckId(name);\n var deck = new Deck { Id = id, Name = name.Trim(), CreatedAt = DateTime.UtcNow.ToString(\"O\") };\n Store.Decks.Add(deck);\n await SaveAsync();\n return deck;\n }\n\n private string UniqueDeckId(string name)\n {\n var baseId = Slug(name);\n if (string.Equals(baseId, MainDeckId, StringComparison.OrdinalIgnoreCase)) baseId = \"deck-\" + baseId;\n var id = baseId;\n var n = 2;\n while (Store.Decks.Any(d => string.Equals(d.Id, id, StringComparison.OrdinalIgnoreCase))) id = $\"{baseId}-{n++}\";\n return id;\n }\n\n public async Task RenameDeckAsync(string deckId, string name)\n {\n var d = Store.Decks.FirstOrDefault(x => string.Equals(x.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (d is null || string.IsNullOrWhiteSpace(name)) return;\n d.Name = name.Trim();\n await SaveAsync();\n }\n\n // Delete a deck (never Main) - its collections move to Main so nothing is orphaned.\n public async Task DeleteDeckAsync(string deckId)\n {\n if (string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase)) return;\n foreach (var c in Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)))\n c.DeckId = MainDeckId;\n Store.Decks.RemoveAll(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (string.Equals(Store.Settings.ActiveDeckId, deckId, StringComparison.OrdinalIgnoreCase))\n Store.Settings.ActiveDeckId = MainDeckId;\n await SaveAsync();\n }\n\n public async Task SetActiveDeckAsync(string deckId)\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n Store.Settings.ActiveDeckId = deckId;\n await SaveAsync();\n }\n\n public async Task MoveCollectionToDeckAsync(string collectionId, string deckId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return;\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n c.DeckId = ResolveDeckId(deckId);\n await SaveAsync();\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Per-collection Memory Bank / Project Brain vaults live here, a sibling of the backups dir.\n public string BrainsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"brains\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n var deck = ResolveDeckId(cmd.deck);\n await AddToCollectionAsync(s, cmd.project!, deck);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var colId = DeckCollectionId(cmd.project!, deck);\n var persisted = Store.Collections.TryGetValue(colId, out var collection)\n && collection.SessionIds.Contains(s.Id);\n var deckName = Store.Decks.FirstOrDefault(d => string.Equals(d.Id, deck, StringComparison.OrdinalIgnoreCase))?.Name ?? \"Main\";\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\" on deck \\\"{deckName}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n case \"tag\":\n case \"untag\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(op), inputId);\n var tags = (cmd.tags ?? new List()).ToList();\n if (!string.IsNullOrWhiteSpace(cmd.name)) tags.Add(cmd.name!);\n tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();\n if (tags.Count == 0) return new AgentCommandResult(false, $\"{op} needs 'tags' (array) or 'name'.\", inputId, s.Id);\n var changed = 0;\n foreach (var t in tags)\n changed += (op == \"tag\" ? AddTagTo(s.Tags, t, reserved: true) : RemoveTagFrom(s.Tags, t)) ? 1 : 0;\n if (changed > 0) { await SaveAsync(); RefreshSessions(Store.Sessions.Values); }\n var verb = op == \"tag\" ? \"Tagged\" : \"Untagged\";\n return new AgentCommandResult(true, $\"{verb} \\\"{s.DisplayTitle}\\\" ({changed} change{(changed == 1 ? \"\" : \"s\")}).\", inputId, s.Id, Persisted: changed > 0);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return await RefreshIndexedSessionAsync(s, ExplicitId(cmd), cmd.tool);\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Exact-id/self commands must not act on stale metadata. A long Claude session can keep the same\n // resumable id while new turns are appended far past the original indexed window, so refresh the\n // matched source file before filing/favoriting/naming it.\n private async Task RefreshIndexedSessionAsync(ArchiveSession existing, string? expectedId, string? tool)\n {\n try\n {\n var path = string.IsNullOrWhiteSpace(existing.SourcePath) ? \"\"\n : Path.IsPathRooted(existing.SourcePath) ? existing.SourcePath : Path.Combine(_rootPath, existing.SourcePath);\n if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return existing;\n\n var parsed = await ParseSessionAsync(path, string.IsNullOrWhiteSpace(tool) ? existing.Tool : tool!);\n if (parsed is null) return existing;\n\n var targetId = !string.IsNullOrWhiteSpace(expectedId) ? expectedId! : existing.Id;\n if (!SessionHasIdOrAlias(parsed, targetId) && !string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase))\n return existing;\n\n PreserveAppFields(existing, parsed);\n if (!string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase)) Store.Sessions.Remove(existing.Id);\n Store.Sessions[parsed.Id] = parsed;\n\n var info = new FileInfo(path);\n Store.FileStamps[path] = info.LastWriteTimeUtc.Ticks + \":\" + info.Length;\n return parsed;\n }\n catch\n {\n return existing;\n }\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n // ---- Tagging & filtering -------------------------------------------------------------------\n // Reserved tags are auto-managed (archive = \"lives in the archive\", code = \"has code blocks\") and\n // are hidden from the tag UI/filters so user tags stay clean. Everything else is a user tag.\n public static readonly IReadOnlyCollection ReservedTags =\n new HashSet(StringComparer.OrdinalIgnoreCase) { \"archive\", \"code\" };\n\n public static bool IsReservedTag(string? tag) => !string.IsNullOrWhiteSpace(tag) && ReservedTags.Contains(tag.Trim());\n\n public static string NormalizeTag(string? tag)\n {\n var t = Regex.Replace((tag ?? \"\").Trim(), \"\\\\s+\", \" \");\n return t.Length > 40 ? t[..40].Trim() : t;\n }\n\n // The user-facing tags on a chat (reserved tags removed), sorted, de-duped case-insensitively.\n public static IReadOnlyList UserTags(ArchiveSession session) =>\n session.Tags\n .Where(t => !string.IsNullOrWhiteSpace(t) && !IsReservedTag(t))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public async Task AddChatTagAsync(ArchiveSession session, string tag)\n {\n if (!AddTagTo(session.Tags, tag, reserved: true)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RemoveChatTagAsync(ArchiveSession session, string tag)\n {\n if (!RemoveTagFrom(session.Tags, tag)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task AddCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!AddTagTo(c.Tags, tag, reserved: false)) return false;\n await SaveAsync();\n return true;\n }\n\n public async Task RemoveCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!RemoveTagFrom(c.Tags, tag)) return false;\n await SaveAsync();\n return true;\n }\n\n // All user chat tags with counts (most-used first, then alpha) - drives the chat filter strip.\n public IReadOnlyList AllChatTags() =>\n Store.Sessions.Values\n .Where(s => !s.Archived)\n .SelectMany(UserTags)\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public IReadOnlyList AllCollectionTags() =>\n Store.Collections.Values\n .SelectMany(c => c.Tags.Where(t => !string.IsNullOrWhiteSpace(t)))\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n // Back-compat thin wrapper: text + an ANY/ALL include-tag filter.\n public IReadOnlyList Filter(string query, IReadOnlyCollection? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty()).ToList(), MatchAllIncludes = matchAll });\n\n private static bool SessionHasTag(ArchiveSession s, string tag) =>\n s.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // The compound filter: text search, then restrict to a collection (if set), then keep chats that\n // satisfy the include set (ANY or ALL) and carry NONE of the exclude set.\n public IReadOnlyList FilterChats(ChatFilter f)\n {\n IEnumerable baseSet = string.IsNullOrWhiteSpace(f.Query)\n ? OrderedVisibleSessions(Store.Sessions.Values)\n : Search(f.Query);\n\n if (!string.IsNullOrEmpty(f.CollectionId) && Store.Collections.TryGetValue(f.CollectionId, out var col))\n {\n var ids = new HashSet(col.SessionIds, StringComparer.OrdinalIgnoreCase);\n baseSet = baseSet.Where(s => ids.Contains(s.Id));\n }\n\n return baseSet\n .Where(s => f.IncludeTags.Count == 0\n || (f.MatchAllIncludes\n ? f.IncludeTags.All(t => SessionHasTag(s, t))\n : f.IncludeTags.Any(t => SessionHasTag(s, t))))\n .Where(s => f.ExcludeTags.Count == 0 || !f.ExcludeTags.Any(t => SessionHasTag(s, t)))\n .ToList();\n }\n\n // ---- Collection (project) filtering + demote ordering --------------------------------------\n private static bool CollectionHasTag(ArchiveCollection c, string tag) =>\n c.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // Filter collections by their own tags (compound): keep those satisfying the include set (ANY or\n // ALL) and carrying NONE of the exclude set. \"active + graphics, not web-dev\" -> include both, exclude web-dev.\n public IReadOnlyList FilterCollections(IReadOnlyCollection include, IReadOnlyCollection exclude, bool matchAll = false)\n {\n return Store.Collections.Values\n .Where(c => include.Count == 0\n || (matchAll ? include.All(t => CollectionHasTag(c, t)) : include.Any(t => CollectionHasTag(c, t))))\n .Where(c => exclude.Count == 0 || !exclude.Any(t => CollectionHasTag(c, t)))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n }\n\n // Layer rules: a tag can be assigned a layer (LOWER number = HIGHER in the list). Unassigned tags\n // use DefaultLayer, so layered tags can float above (e.g. active=1) or sink below (e.g. context=900).\n public const int DefaultLayer = 100;\n\n public int TagLayer(string tag) =>\n Store.TagLayers.TryGetValue(TagColorKey(tag), out var n) ? n : DefaultLayer;\n\n // A chat's layer = the strongest (lowest) EXPLICITLY-assigned layer among its tags; DefaultLayer\n // if none of its tags has a layer rule. (Unlayered tags don't pull a context chat back to the middle.)\n public int SessionLayer(ArchiveSession s)\n {\n int? best = null;\n foreach (var t in UserTags(s))\n if (Store.TagLayers.TryGetValue(TagColorKey(t), out var l))\n best = best is null ? l : Math.Min(best.Value, l);\n return best ?? DefaultLayer;\n }\n\n public async Task SetTagLayerAsync(string tag, int? layer)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (layer is null || layer == DefaultLayer) Store.TagLayers.Remove(key);\n else Store.TagLayers[key] = layer.Value;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Order a collection's chats: pinned first, then by layer (active up / context down), then by the\n // collection's MANUAL order (its SessionIds sequence), then recency. So manual drags persist and\n // layer rules group on top of them.\n public IReadOnlyList OrderCollectionChats(ArchiveCollection col, IEnumerable sessions)\n {\n var manual = new Dictionary(StringComparer.OrdinalIgnoreCase);\n for (var i = 0; i < col.SessionIds.Count; i++) manual.TryAdd(col.SessionIds[i], i);\n return sessions\n .OrderByDescending(s => s.Pinned)\n .ThenBy(s => SessionLayer(s))\n .ThenBy(s => manual.TryGetValue(s.Id, out var i) ? i : int.MaxValue)\n .ThenByDescending(s => s.UpdatedAt)\n .ToList();\n }\n\n // Move a chat within a collection's manual order by delta (negative = up). toEnd jumps to top/bottom.\n public async Task ReorderInCollectionAsync(string collectionId, string sessionId, int delta, bool toEnd = false)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n var i = col.SessionIds.FindIndex(x => string.Equals(x, sessionId, StringComparison.OrdinalIgnoreCase));\n if (i < 0) return;\n var target = toEnd ? (delta < 0 ? 0 : col.SessionIds.Count - 1) : Math.Clamp(i + delta, 0, col.SessionIds.Count - 1);\n if (target == i) return;\n col.SessionIds.RemoveAt(i);\n col.SessionIds.Insert(target, sessionId);\n await SaveAsync();\n }\n\n // ---- Tag colors ----------------------------------------------------------------------------\n // A curated palette that reads on pure-black AMOLED and coexists with the rose accent (Tailwind-400\n // family). Tags get a deterministic auto-color from this by name hash; users can override per tag.\n // Rose (#FB7185) is deliberately excluded - it's reserved for the accent / active-filter state.\n public static readonly IReadOnlyList TagPalette = new[]\n {\n \"#60A5FA\", \"#38BDF8\", \"#22D3EE\", \"#2DD4BF\", \"#34D399\",\n \"#A3E635\", \"#FBBF24\", \"#FB923C\", \"#F87171\", \"#A78BFA\"\n };\n\n private static string TagColorKey(string tag) => NormalizeTag(tag).ToLowerInvariant();\n\n // Stable across runs (string.GetHashCode is randomized in .NET, so use FNV-1a).\n private static uint Fnv1a(string s)\n {\n uint hash = 2166136261;\n foreach (var ch in s) { hash ^= ch; hash *= 16777619; }\n return hash;\n }\n\n public static string AutoTagColor(string tag)\n {\n var key = NormalizeTag(tag).ToLowerInvariant();\n if (key.Length == 0) return TagPalette[0];\n return TagPalette[(int)(Fnv1a(key) % (uint)TagPalette.Count)];\n }\n\n // The effective color for a tag: a user override if set, else the deterministic auto-color.\n public string TagColor(string tag)\n {\n var key = TagColorKey(tag);\n return Store.TagColors.TryGetValue(key, out var hex) && !string.IsNullOrWhiteSpace(hex)\n ? hex\n : AutoTagColor(tag);\n }\n\n public bool HasCustomTagColor(string tag) => Store.TagColors.ContainsKey(TagColorKey(tag));\n\n // Set (or clear, when hex is null/blank) a tag's color override; clearing returns it to auto.\n public async Task SetTagColorAsync(string tag, string? hex)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (string.IsNullOrWhiteSpace(hex)) Store.TagColors.Remove(key);\n else Store.TagColors[key] = hex.Trim();\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Generic add/remove against either tag store (ObservableCollection for chats, List for collections).\n private static bool AddTagTo(ICollection store, string tag, bool reserved)\n {\n var t = NormalizeTag(tag);\n if (t.Length == 0 || (reserved && IsReservedTag(t))) return false;\n if (store.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) return false;\n store.Add(t);\n return true;\n }\n\n private static bool RemoveTagFrom(ICollection store, string tag)\n {\n var match = store.FirstOrDefault(x => string.Equals(x, (tag ?? \"\").Trim(), StringComparison.OrdinalIgnoreCase));\n if (match is null) return false;\n store.Remove(match);\n return true;\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n // Auto-detect per file so a root holding Claude transcripts (or a mix) is routed correctly\n // instead of being force-parsed as Codex — otherwise a Claude .jsonl yields zero messages and\n // is mis-tagged tool=codex (which then breaks ParseFullAsync / the brain source layer).\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"auto\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // The shared, FULL-history result of parsing one transcript, BEFORE the recent-message window is\n // applied. The normal indexing path (ParseJsonlAsync/ParseClaudeSessionAsync) wraps this and trims\n // to the window; the brain wants the untrimmed list for stable anchoring. ONE source of truth for\n // message extraction — a drift-guard test asserts the windowed tail equals this list's tail.\n private sealed record ParsedTranscript(\n ObservableCollection Messages,\n ObservableCollection CodeBlocks,\n string Id, string Title, string Created, string Updated,\n string Cwd, HashSet Aliases, int Total, string Tool);\n\n // Brain source layer: re-parse a session's raw transcript WITHOUT the 600-message window, yielding\n // the full ordered message list with a stable per-message Index (0-based over the whole transcript)\n // — the basis for source blocks and anchors. Reuses the exact same extraction core as indexing.\n public async Task> ParseFullAsync(ArchiveSession session)\n {\n if (session is null || string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath))\n return Array.Empty();\n var tool = string.IsNullOrWhiteSpace(session.Tool) || session.Tool == \"auto\"\n ? DetectTool(session.SourcePath) : session.Tool;\n var core = tool == \"claude\"\n ? await ParseClaudeCoreAsync(session.SourcePath)\n : await ParseCodexCoreAsync(session.SourcePath);\n if (core is null) return Array.Empty();\n var list = new List(core.Messages.Count);\n for (var i = 0; i < core.Messages.Count; i++)\n {\n var m = core.Messages[i];\n list.Add(new FullMessage(i, m.Role, m.Text, m.Timestamp, m.EffectiveKind == \"tool\" ? m.ToolName : null));\n }\n return list;\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n // Claude resume is directory-scoped (it only finds the session under the launch dir's encoded\n // project folder), so it needs the recovered launch dir — not the recorded workspace subdir.\n var cwd = isClaude ? ResolveClaudeResumeDirectory(session) : ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n // Claude Code files a transcript under a project folder named by DASH-ENCODING the directory that\n // `claude` was launched from (each \\ / : . and whitespace becomes '-'). `claude --resume `\n // only finds the session when run from THAT directory. The cwd recorded inside the transcript can\n // be a SUBDIR of the launch dir (e.g. launched in …\\301, cwd later moved to …\\301\\graphics), so\n // resuming from the recorded workspace fails with \"No conversation found with session ID\". Recover\n // the real launch dir by climbing from the workspace until a directory's encoding matches the\n // transcript's project-folder name. Falls back to the recorded workspace if nothing matches.\n private static string ResolveClaudeResumeDirectory(ArchiveSession session)\n {\n var fallback = ResolveWorkingDirectory(session);\n var projectFolder = Path.GetFileName(Path.GetDirectoryName(session.SourcePath) ?? \"\");\n if (string.IsNullOrWhiteSpace(projectFolder)) return fallback;\n\n foreach (var start in new[] { session.Workspace, fallback })\n {\n var dir = string.IsNullOrWhiteSpace(start) ? null : start;\n for (var guard = 0; guard < 64 && !string.IsNullOrWhiteSpace(dir); guard++)\n {\n if (string.Equals(EncodeClaudeProjectFolder(dir!), projectFolder, StringComparison.OrdinalIgnoreCase)\n && Directory.Exists(dir!))\n return dir!;\n dir = Path.GetDirectoryName(dir!.TrimEnd('\\\\', '/'));\n }\n }\n return fallback;\n }\n\n // Mirror Claude Code's project-folder encoding: every path separator, drive colon, dot, or\n // whitespace char becomes '-', preserving case and any literal dashes already in the path.\n private static string EncodeClaudeProjectFolder(string path) =>\n Regex.Replace(path.TrimEnd('\\\\', '/'), @\"[\\\\/:.\\s]\", \"-\");\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return ElementText(v);\n }\n\n private static string ElementText(JsonElement v) =>\n v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n\n private static string ElementString(JsonElement v) =>\n v.ValueKind == JsonValueKind.String ? v.GetString() ?? \"\" : \"\";\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseCodexCoreAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? ElementString(rtProp) : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && ElementString(sidProp) is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && ElementString(midProp) is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && ElementString(forkProp) is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp) && ElementString(cwdProp) is { Length: > 0 } cw) cwd = cw;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? ElementString(typeProp) : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = Field(payload, \"message\");\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = Field(payload, \"name\");\n if (string.IsNullOrWhiteSpace(name)) name = \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = Field(payload, \"output\");\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = Field(payload, \"role\");\n if (string.IsNullOrWhiteSpace(role)) role = \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ParsedTranscript(messages, codeBlocks, id, title, created, updated, cwd, aliases, messages.Count, \"codex\");\n }\n\n // Thin wrapper: full Codex parse, then trim to the recent-message window for the in-app reader.\n private async Task ParseJsonlAsync(string filePath)\n {\n var p = await ParseCodexCoreAsync(filePath);\n var (messages, codeBlocks) = KeepRecentWindow(p.Messages, p.CodeBlocks);\n return new ArchiveSession\n {\n Id = p.Id,\n Title = p.Title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(p.Aliases.Where(a => !string.Equals(a, p.Id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = p.Created,\n UpdatedAt = p.Updated,\n Workspace = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown workspace\" : p.Cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown\" : Path.GetFileName(p.Cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = p.Total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeCoreAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n return new ParsedTranscript(messages, codeBlocks, id, title, created, updated, cwd,\n new HashSet(StringComparer.OrdinalIgnoreCase), messages.Count, \"claude\");\n }\n\n // Thin wrapper: full Claude parse (null = sidechain/subagent file), then trim to the reader window.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var p = await ParseClaudeCoreAsync(filePath);\n if (p is null) return null;\n var (messages, codeBlocks) = KeepRecentWindow(p.Messages, p.CodeBlocks);\n return new ArchiveSession\n {\n Id = p.Id,\n Title = p.Title,\n SourcePath = filePath,\n CreatedAt = p.Created,\n UpdatedAt = p.Updated,\n Workspace = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown workspace\" : p.Cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(p.Cwd) ? \"Unknown\" : Path.GetFileName(p.Cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = p.Total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = ElementText(argsProp);\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "db3e569cfdbf1ae8b8c7399daa6304e4fc907c22": {"t": 1785756770.216061, "n": 128925, "h": "6a63bd57e98819bf", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 10; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n EnsureDecks();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n try\n {\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n }\n catch (Exception ex)\n {\n session.Messages = new ObservableCollection\n {\n new()\n {\n Id = \"load-error\",\n Role = \"assistant\",\n Timestamp = session.UpdatedAt,\n Text = $\"Could not load the full transcript from disk.\\n\\n{ex.Message}\\n\\nSource: {path}\"\n }\n };\n session.CodeBlocks = new ObservableCollection();\n session.MessageCount = session.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n Decks = new List(Store.Decks),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n TagColors = new Dictionary(Store.TagColors),\n TagLayers = new Dictionary(Store.TagLayers),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? MainDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n }\n else if (string.IsNullOrWhiteSpace(collection.DeckId)) collection.DeckId = deck;\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName, string? deckId = null)\n {\n var deck = ResolveDeckId(deckId ?? ActiveDeckId);\n var id = DeckCollectionId(collectionName, deck);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex, DeckId = deck };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n // Add a chat to an EXISTING collection by its id (used by the \"Add to collection\" menus, which\n // already know the collection - avoids any name/deck ambiguity).\n public async Task AddToCollectionByIdAsync(ArchiveSession session, string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n if (!col.SessionIds.Contains(session.Id)) col.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // ---- Decks (top-level groupings of collections) --------------------------------------------\n public const string MainDeckId = \"main\";\n\n public void EnsureDecks()\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, MainDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Decks.Insert(0, new Deck { Id = MainDeckId, Name = \"Main\" });\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId)\n || !Store.Decks.Any(d => string.Equals(d.Id, Store.Settings.ActiveDeckId, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.ActiveDeckId = MainDeckId;\n }\n\n public IReadOnlyList Decks => Store.Decks;\n public string ActiveDeckId => string.IsNullOrWhiteSpace(Store.Settings.ActiveDeckId) ? MainDeckId : Store.Settings.ActiveDeckId;\n public static string CollectionDeck(ArchiveCollection c) => string.IsNullOrWhiteSpace(c.DeckId) ? MainDeckId : c.DeckId;\n\n public IReadOnlyList CollectionsInDeck(string deckId) =>\n Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)).ToList();\n\n public int CollectionCountInDeck(string deckId) =>\n Store.Collections.Values.Count(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase));\n\n // Resolve a deck reference (id OR name, case-insensitive) to a deck id; null/unknown -> main.\n public string ResolveDeckId(string? deckRef)\n {\n if (string.IsNullOrWhiteSpace(deckRef)) return MainDeckId;\n var d = Store.Decks.FirstOrDefault(x =>\n string.Equals(x.Id, deckRef, StringComparison.OrdinalIgnoreCase) ||\n string.Equals(x.Name, deckRef, StringComparison.OrdinalIgnoreCase));\n return d?.Id ?? MainDeckId;\n }\n\n private string DeckCollectionId(string name, string deckId) =>\n string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase) ? Slug(name) : $\"{Slug(deckId)}--{Slug(name)}\";\n\n public async Task CreateDeckAsync(string name)\n {\n var id = UniqueDeckId(name);\n var deck = new Deck { Id = id, Name = name.Trim(), CreatedAt = DateTime.UtcNow.ToString(\"O\") };\n Store.Decks.Add(deck);\n await SaveAsync();\n return deck;\n }\n\n private string UniqueDeckId(string name)\n {\n var baseId = Slug(name);\n if (string.Equals(baseId, MainDeckId, StringComparison.OrdinalIgnoreCase)) baseId = \"deck-\" + baseId;\n var id = baseId;\n var n = 2;\n while (Store.Decks.Any(d => string.Equals(d.Id, id, StringComparison.OrdinalIgnoreCase))) id = $\"{baseId}-{n++}\";\n return id;\n }\n\n public async Task RenameDeckAsync(string deckId, string name)\n {\n var d = Store.Decks.FirstOrDefault(x => string.Equals(x.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (d is null || string.IsNullOrWhiteSpace(name)) return;\n d.Name = name.Trim();\n await SaveAsync();\n }\n\n // Delete a deck (never Main) - its collections move to Main so nothing is orphaned.\n public async Task DeleteDeckAsync(string deckId)\n {\n if (string.Equals(deckId, MainDeckId, StringComparison.OrdinalIgnoreCase)) return;\n foreach (var c in Store.Collections.Values.Where(c => string.Equals(CollectionDeck(c), deckId, StringComparison.OrdinalIgnoreCase)))\n c.DeckId = MainDeckId;\n Store.Decks.RemoveAll(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase));\n if (string.Equals(Store.Settings.ActiveDeckId, deckId, StringComparison.OrdinalIgnoreCase))\n Store.Settings.ActiveDeckId = MainDeckId;\n await SaveAsync();\n }\n\n public async Task SetActiveDeckAsync(string deckId)\n {\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n Store.Settings.ActiveDeckId = deckId;\n await SaveAsync();\n }\n\n public async Task MoveCollectionToDeckAsync(string collectionId, string deckId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return;\n if (!Store.Decks.Any(d => string.Equals(d.Id, deckId, StringComparison.OrdinalIgnoreCase))) return;\n c.DeckId = ResolveDeckId(deckId);\n await SaveAsync();\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n var deck = ResolveDeckId(cmd.deck);\n await AddToCollectionAsync(s, cmd.project!, deck);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var colId = DeckCollectionId(cmd.project!, deck);\n var persisted = Store.Collections.TryGetValue(colId, out var collection)\n && collection.SessionIds.Contains(s.Id);\n var deckName = Store.Decks.FirstOrDefault(d => string.Equals(d.Id, deck, StringComparison.OrdinalIgnoreCase))?.Name ?? \"Main\";\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\" on deck \\\"{deckName}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n case \"tag\":\n case \"untag\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(op), inputId);\n var tags = (cmd.tags ?? new List()).ToList();\n if (!string.IsNullOrWhiteSpace(cmd.name)) tags.Add(cmd.name!);\n tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();\n if (tags.Count == 0) return new AgentCommandResult(false, $\"{op} needs 'tags' (array) or 'name'.\", inputId, s.Id);\n var changed = 0;\n foreach (var t in tags)\n changed += (op == \"tag\" ? AddTagTo(s.Tags, t, reserved: true) : RemoveTagFrom(s.Tags, t)) ? 1 : 0;\n if (changed > 0) { await SaveAsync(); RefreshSessions(Store.Sessions.Values); }\n var verb = op == \"tag\" ? \"Tagged\" : \"Untagged\";\n return new AgentCommandResult(true, $\"{verb} \\\"{s.DisplayTitle}\\\" ({changed} change{(changed == 1 ? \"\" : \"s\")}).\", inputId, s.Id, Persisted: changed > 0);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return await RefreshIndexedSessionAsync(s, ExplicitId(cmd), cmd.tool);\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Exact-id/self commands must not act on stale metadata. A long Claude session can keep the same\n // resumable id while new turns are appended far past the original indexed window, so refresh the\n // matched source file before filing/favoriting/naming it.\n private async Task RefreshIndexedSessionAsync(ArchiveSession existing, string? expectedId, string? tool)\n {\n try\n {\n var path = string.IsNullOrWhiteSpace(existing.SourcePath) ? \"\"\n : Path.IsPathRooted(existing.SourcePath) ? existing.SourcePath : Path.Combine(_rootPath, existing.SourcePath);\n if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return existing;\n\n var parsed = await ParseSessionAsync(path, string.IsNullOrWhiteSpace(tool) ? existing.Tool : tool!);\n if (parsed is null) return existing;\n\n var targetId = !string.IsNullOrWhiteSpace(expectedId) ? expectedId! : existing.Id;\n if (!SessionHasIdOrAlias(parsed, targetId) && !string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase))\n return existing;\n\n PreserveAppFields(existing, parsed);\n if (!string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase)) Store.Sessions.Remove(existing.Id);\n Store.Sessions[parsed.Id] = parsed;\n\n var info = new FileInfo(path);\n Store.FileStamps[path] = info.LastWriteTimeUtc.Ticks + \":\" + info.Length;\n return parsed;\n }\n catch\n {\n return existing;\n }\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n // ---- Tagging & filtering -------------------------------------------------------------------\n // Reserved tags are auto-managed (archive = \"lives in the archive\", code = \"has code blocks\") and\n // are hidden from the tag UI/filters so user tags stay clean. Everything else is a user tag.\n public static readonly IReadOnlyCollection ReservedTags =\n new HashSet(StringComparer.OrdinalIgnoreCase) { \"archive\", \"code\" };\n\n public static bool IsReservedTag(string? tag) => !string.IsNullOrWhiteSpace(tag) && ReservedTags.Contains(tag.Trim());\n\n public static string NormalizeTag(string? tag)\n {\n var t = Regex.Replace((tag ?? \"\").Trim(), \"\\\\s+\", \" \");\n return t.Length > 40 ? t[..40].Trim() : t;\n }\n\n // The user-facing tags on a chat (reserved tags removed), sorted, de-duped case-insensitively.\n public static IReadOnlyList UserTags(ArchiveSession session) =>\n session.Tags\n .Where(t => !string.IsNullOrWhiteSpace(t) && !IsReservedTag(t))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public async Task AddChatTagAsync(ArchiveSession session, string tag)\n {\n if (!AddTagTo(session.Tags, tag, reserved: true)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RemoveChatTagAsync(ArchiveSession session, string tag)\n {\n if (!RemoveTagFrom(session.Tags, tag)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task AddCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!AddTagTo(c.Tags, tag, reserved: false)) return false;\n await SaveAsync();\n return true;\n }\n\n public async Task RemoveCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!RemoveTagFrom(c.Tags, tag)) return false;\n await SaveAsync();\n return true;\n }\n\n // All user chat tags with counts (most-used first, then alpha) - drives the chat filter strip.\n public IReadOnlyList AllChatTags() =>\n Store.Sessions.Values\n .Where(s => !s.Archived)\n .SelectMany(UserTags)\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public IReadOnlyList AllCollectionTags() =>\n Store.Collections.Values\n .SelectMany(c => c.Tags.Where(t => !string.IsNullOrWhiteSpace(t)))\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n // Back-compat thin wrapper: text + an ANY/ALL include-tag filter.\n public IReadOnlyList Filter(string query, IReadOnlyCollection? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty()).ToList(), MatchAllIncludes = matchAll });\n\n private static bool SessionHasTag(ArchiveSession s, string tag) =>\n s.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // The compound filter: text search, then restrict to a collection (if set), then keep chats that\n // satisfy the include set (ANY or ALL) and carry NONE of the exclude set.\n public IReadOnlyList FilterChats(ChatFilter f)\n {\n IEnumerable baseSet = string.IsNullOrWhiteSpace(f.Query)\n ? OrderedVisibleSessions(Store.Sessions.Values)\n : Search(f.Query);\n\n if (!string.IsNullOrEmpty(f.CollectionId) && Store.Collections.TryGetValue(f.CollectionId, out var col))\n {\n var ids = new HashSet(col.SessionIds, StringComparer.OrdinalIgnoreCase);\n baseSet = baseSet.Where(s => ids.Contains(s.Id));\n }\n\n return baseSet\n .Where(s => f.IncludeTags.Count == 0\n || (f.MatchAllIncludes\n ? f.IncludeTags.All(t => SessionHasTag(s, t))\n : f.IncludeTags.Any(t => SessionHasTag(s, t))))\n .Where(s => f.ExcludeTags.Count == 0 || !f.ExcludeTags.Any(t => SessionHasTag(s, t)))\n .ToList();\n }\n\n // ---- Collection (project) filtering + demote ordering --------------------------------------\n private static bool CollectionHasTag(ArchiveCollection c, string tag) =>\n c.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // Filter collections by their own tags (compound): keep those satisfying the include set (ANY or\n // ALL) and carrying NONE of the exclude set. \"active + graphics, not web-dev\" -> include both, exclude web-dev.\n public IReadOnlyList FilterCollections(IReadOnlyCollection include, IReadOnlyCollection exclude, bool matchAll = false)\n {\n return Store.Collections.Values\n .Where(c => include.Count == 0\n || (matchAll ? include.All(t => CollectionHasTag(c, t)) : include.Any(t => CollectionHasTag(c, t))))\n .Where(c => exclude.Count == 0 || !exclude.Any(t => CollectionHasTag(c, t)))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n }\n\n // Layer rules: a tag can be assigned a layer (LOWER number = HIGHER in the list). Unassigned tags\n // use DefaultLayer, so layered tags can float above (e.g. active=1) or sink below (e.g. context=900).\n public const int DefaultLayer = 100;\n\n public int TagLayer(string tag) =>\n Store.TagLayers.TryGetValue(TagColorKey(tag), out var n) ? n : DefaultLayer;\n\n // A chat's layer = the strongest (lowest) EXPLICITLY-assigned layer among its tags; DefaultLayer\n // if none of its tags has a layer rule. (Unlayered tags don't pull a context chat back to the middle.)\n public int SessionLayer(ArchiveSession s)\n {\n int? best = null;\n foreach (var t in UserTags(s))\n if (Store.TagLayers.TryGetValue(TagColorKey(t), out var l))\n best = best is null ? l : Math.Min(best.Value, l);\n return best ?? DefaultLayer;\n }\n\n public async Task SetTagLayerAsync(string tag, int? layer)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (layer is null || layer == DefaultLayer) Store.TagLayers.Remove(key);\n else Store.TagLayers[key] = layer.Value;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Order a collection's chats: pinned first, then by layer (active up / context down), then by the\n // collection's MANUAL order (its SessionIds sequence), then recency. So manual drags persist and\n // layer rules group on top of them.\n public IReadOnlyList OrderCollectionChats(ArchiveCollection col, IEnumerable sessions)\n {\n var manual = new Dictionary(StringComparer.OrdinalIgnoreCase);\n for (var i = 0; i < col.SessionIds.Count; i++) manual.TryAdd(col.SessionIds[i], i);\n return sessions\n .OrderByDescending(s => s.Pinned)\n .ThenBy(s => SessionLayer(s))\n .ThenBy(s => manual.TryGetValue(s.Id, out var i) ? i : int.MaxValue)\n .ThenByDescending(s => s.UpdatedAt)\n .ToList();\n }\n\n // Move a chat within a collection's manual order by delta (negative = up). toEnd jumps to top/bottom.\n public async Task ReorderInCollectionAsync(string collectionId, string sessionId, int delta, bool toEnd = false)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n var i = col.SessionIds.FindIndex(x => string.Equals(x, sessionId, StringComparison.OrdinalIgnoreCase));\n if (i < 0) return;\n var target = toEnd ? (delta < 0 ? 0 : col.SessionIds.Count - 1) : Math.Clamp(i + delta, 0, col.SessionIds.Count - 1);\n if (target == i) return;\n col.SessionIds.RemoveAt(i);\n col.SessionIds.Insert(target, sessionId);\n await SaveAsync();\n }\n\n // ---- Tag colors ----------------------------------------------------------------------------\n // A curated palette that reads on pure-black AMOLED and coexists with the rose accent (Tailwind-400\n // family). Tags get a deterministic auto-color from this by name hash; users can override per tag.\n // Rose (#FB7185) is deliberately excluded - it's reserved for the accent / active-filter state.\n public static readonly IReadOnlyList TagPalette = new[]\n {\n \"#60A5FA\", \"#38BDF8\", \"#22D3EE\", \"#2DD4BF\", \"#34D399\",\n \"#A3E635\", \"#FBBF24\", \"#FB923C\", \"#F87171\", \"#A78BFA\"\n };\n\n private static string TagColorKey(string tag) => NormalizeTag(tag).ToLowerInvariant();\n\n // Stable across runs (string.GetHashCode is randomized in .NET, so use FNV-1a).\n private static uint Fnv1a(string s)\n {\n uint hash = 2166136261;\n foreach (var ch in s) { hash ^= ch; hash *= 16777619; }\n return hash;\n }\n\n public static string AutoTagColor(string tag)\n {\n var key = NormalizeTag(tag).ToLowerInvariant();\n if (key.Length == 0) return TagPalette[0];\n return TagPalette[(int)(Fnv1a(key) % (uint)TagPalette.Count)];\n }\n\n // The effective color for a tag: a user override if set, else the deterministic auto-color.\n public string TagColor(string tag)\n {\n var key = TagColorKey(tag);\n return Store.TagColors.TryGetValue(key, out var hex) && !string.IsNullOrWhiteSpace(hex)\n ? hex\n : AutoTagColor(tag);\n }\n\n public bool HasCustomTagColor(string tag) => Store.TagColors.ContainsKey(TagColorKey(tag));\n\n // Set (or clear, when hex is null/blank) a tag's color override; clearing returns it to auto.\n public async Task SetTagColorAsync(string tag, string? hex)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (string.IsNullOrWhiteSpace(hex)) Store.TagColors.Remove(key);\n else Store.TagColors[key] = hex.Trim();\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Generic add/remove against either tag store (ObservableCollection for chats, List for collections).\n private static bool AddTagTo(ICollection store, string tag, bool reserved)\n {\n var t = NormalizeTag(tag);\n if (t.Length == 0 || (reserved && IsReservedTag(t))) return false;\n if (store.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) return false;\n store.Add(t);\n return true;\n }\n\n private static bool RemoveTagFrom(ICollection store, string tag)\n {\n var match = store.FirstOrDefault(x => string.Equals(x, (tag ?? \"\").Trim(), StringComparison.OrdinalIgnoreCase));\n if (match is null) return false;\n store.Remove(match);\n return true;\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return ElementText(v);\n }\n\n private static string ElementText(JsonElement v) =>\n v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n\n private static string ElementString(JsonElement v) =>\n v.ValueKind == JsonValueKind.String ? v.GetString() ?? \"\" : \"\";\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? ElementString(rtProp) : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && ElementString(sidProp) is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && ElementString(midProp) is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && ElementString(forkProp) is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp) && ElementString(cwdProp) is { Length: > 0 } cw) cwd = cw;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? ElementString(typeProp) : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = Field(payload, \"message\");\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = Field(payload, \"name\");\n if (string.IsNullOrWhiteSpace(name)) name = \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = Field(payload, \"output\");\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = Field(payload, \"role\");\n if (string.IsNullOrWhiteSpace(role)) role = \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(aliases.Where(a => !string.Equals(a, id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = ElementText(argsProp);\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "5d4f53f8717bdaab3b86f1a8aa76e84dacfbaf1e": {"t": 1785756771.9420047, "n": 123512, "h": "f51cb9b91ec01307", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 10; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n try\n {\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n }\n catch (Exception ex)\n {\n session.Messages = new ObservableCollection\n {\n new()\n {\n Id = \"load-error\",\n Role = \"assistant\",\n Timestamp = session.UpdatedAt,\n Text = $\"Could not load the full transcript from disk.\\n\\n{ex.Message}\\n\\nSource: {path}\"\n }\n };\n session.CodeBlocks = new ObservableCollection();\n session.MessageCount = session.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n TagColors = new Dictionary(Store.TagColors),\n TagLayers = new Dictionary(Store.TagLayers),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n await AddToCollectionAsync(s, cmd.project!);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var persisted = Store.Collections.TryGetValue(Slug(cmd.project!), out var collection)\n && collection.SessionIds.Contains(s.Id);\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n case \"tag\":\n case \"untag\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(op), inputId);\n var tags = (cmd.tags ?? new List()).ToList();\n if (!string.IsNullOrWhiteSpace(cmd.name)) tags.Add(cmd.name!);\n tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();\n if (tags.Count == 0) return new AgentCommandResult(false, $\"{op} needs 'tags' (array) or 'name'.\", inputId, s.Id);\n var changed = 0;\n foreach (var t in tags)\n changed += (op == \"tag\" ? AddTagTo(s.Tags, t, reserved: true) : RemoveTagFrom(s.Tags, t)) ? 1 : 0;\n if (changed > 0) { await SaveAsync(); RefreshSessions(Store.Sessions.Values); }\n var verb = op == \"tag\" ? \"Tagged\" : \"Untagged\";\n return new AgentCommandResult(true, $\"{verb} \\\"{s.DisplayTitle}\\\" ({changed} change{(changed == 1 ? \"\" : \"s\")}).\", inputId, s.Id, Persisted: changed > 0);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return await RefreshIndexedSessionAsync(s, ExplicitId(cmd), cmd.tool);\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Exact-id/self commands must not act on stale metadata. A long Claude session can keep the same\n // resumable id while new turns are appended far past the original indexed window, so refresh the\n // matched source file before filing/favoriting/naming it.\n private async Task RefreshIndexedSessionAsync(ArchiveSession existing, string? expectedId, string? tool)\n {\n try\n {\n var path = string.IsNullOrWhiteSpace(existing.SourcePath) ? \"\"\n : Path.IsPathRooted(existing.SourcePath) ? existing.SourcePath : Path.Combine(_rootPath, existing.SourcePath);\n if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return existing;\n\n var parsed = await ParseSessionAsync(path, string.IsNullOrWhiteSpace(tool) ? existing.Tool : tool!);\n if (parsed is null) return existing;\n\n var targetId = !string.IsNullOrWhiteSpace(expectedId) ? expectedId! : existing.Id;\n if (!SessionHasIdOrAlias(parsed, targetId) && !string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase))\n return existing;\n\n PreserveAppFields(existing, parsed);\n if (!string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase)) Store.Sessions.Remove(existing.Id);\n Store.Sessions[parsed.Id] = parsed;\n\n var info = new FileInfo(path);\n Store.FileStamps[path] = info.LastWriteTimeUtc.Ticks + \":\" + info.Length;\n return parsed;\n }\n catch\n {\n return existing;\n }\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n // ---- Tagging & filtering -------------------------------------------------------------------\n // Reserved tags are auto-managed (archive = \"lives in the archive\", code = \"has code blocks\") and\n // are hidden from the tag UI/filters so user tags stay clean. Everything else is a user tag.\n public static readonly IReadOnlyCollection ReservedTags =\n new HashSet(StringComparer.OrdinalIgnoreCase) { \"archive\", \"code\" };\n\n public static bool IsReservedTag(string? tag) => !string.IsNullOrWhiteSpace(tag) && ReservedTags.Contains(tag.Trim());\n\n public static string NormalizeTag(string? tag)\n {\n var t = Regex.Replace((tag ?? \"\").Trim(), \"\\\\s+\", \" \");\n return t.Length > 40 ? t[..40].Trim() : t;\n }\n\n // The user-facing tags on a chat (reserved tags removed), sorted, de-duped case-insensitively.\n public static IReadOnlyList UserTags(ArchiveSession session) =>\n session.Tags\n .Where(t => !string.IsNullOrWhiteSpace(t) && !IsReservedTag(t))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public async Task AddChatTagAsync(ArchiveSession session, string tag)\n {\n if (!AddTagTo(session.Tags, tag, reserved: true)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RemoveChatTagAsync(ArchiveSession session, string tag)\n {\n if (!RemoveTagFrom(session.Tags, tag)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task AddCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!AddTagTo(c.Tags, tag, reserved: false)) return false;\n await SaveAsync();\n return true;\n }\n\n public async Task RemoveCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!RemoveTagFrom(c.Tags, tag)) return false;\n await SaveAsync();\n return true;\n }\n\n // All user chat tags with counts (most-used first, then alpha) - drives the chat filter strip.\n public IReadOnlyList AllChatTags() =>\n Store.Sessions.Values\n .Where(s => !s.Archived)\n .SelectMany(UserTags)\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public IReadOnlyList AllCollectionTags() =>\n Store.Collections.Values\n .SelectMany(c => c.Tags.Where(t => !string.IsNullOrWhiteSpace(t)))\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n // Back-compat thin wrapper: text + an ANY/ALL include-tag filter.\n public IReadOnlyList Filter(string query, IReadOnlyCollection? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty()).ToList(), MatchAllIncludes = matchAll });\n\n private static bool SessionHasTag(ArchiveSession s, string tag) =>\n s.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // The compound filter: text search, then restrict to a collection (if set), then keep chats that\n // satisfy the include set (ANY or ALL) and carry NONE of the exclude set.\n public IReadOnlyList FilterChats(ChatFilter f)\n {\n IEnumerable baseSet = string.IsNullOrWhiteSpace(f.Query)\n ? OrderedVisibleSessions(Store.Sessions.Values)\n : Search(f.Query);\n\n if (!string.IsNullOrEmpty(f.CollectionId) && Store.Collections.TryGetValue(f.CollectionId, out var col))\n {\n var ids = new HashSet(col.SessionIds, StringComparer.OrdinalIgnoreCase);\n baseSet = baseSet.Where(s => ids.Contains(s.Id));\n }\n\n return baseSet\n .Where(s => f.IncludeTags.Count == 0\n || (f.MatchAllIncludes\n ? f.IncludeTags.All(t => SessionHasTag(s, t))\n : f.IncludeTags.Any(t => SessionHasTag(s, t))))\n .Where(s => f.ExcludeTags.Count == 0 || !f.ExcludeTags.Any(t => SessionHasTag(s, t)))\n .ToList();\n }\n\n // ---- Collection (project) filtering + demote ordering --------------------------------------\n private static bool CollectionHasTag(ArchiveCollection c, string tag) =>\n c.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // Filter collections by their own tags (compound): keep those satisfying the include set (ANY or\n // ALL) and carrying NONE of the exclude set. \"active + graphics, not web-dev\" -> include both, exclude web-dev.\n public IReadOnlyList FilterCollections(IReadOnlyCollection include, IReadOnlyCollection exclude, bool matchAll = false)\n {\n return Store.Collections.Values\n .Where(c => include.Count == 0\n || (matchAll ? include.All(t => CollectionHasTag(c, t)) : include.Any(t => CollectionHasTag(c, t))))\n .Where(c => exclude.Count == 0 || !exclude.Any(t => CollectionHasTag(c, t)))\n .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase)\n .ToList();\n }\n\n // Layer rules: a tag can be assigned a layer (LOWER number = HIGHER in the list). Unassigned tags\n // use DefaultLayer, so layered tags can float above (e.g. active=1) or sink below (e.g. context=900).\n public const int DefaultLayer = 100;\n\n public int TagLayer(string tag) =>\n Store.TagLayers.TryGetValue(TagColorKey(tag), out var n) ? n : DefaultLayer;\n\n // A chat's layer = the strongest (lowest) EXPLICITLY-assigned layer among its tags; DefaultLayer\n // if none of its tags has a layer rule. (Unlayered tags don't pull a context chat back to the middle.)\n public int SessionLayer(ArchiveSession s)\n {\n int? best = null;\n foreach (var t in UserTags(s))\n if (Store.TagLayers.TryGetValue(TagColorKey(t), out var l))\n best = best is null ? l : Math.Min(best.Value, l);\n return best ?? DefaultLayer;\n }\n\n public async Task SetTagLayerAsync(string tag, int? layer)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (layer is null || layer == DefaultLayer) Store.TagLayers.Remove(key);\n else Store.TagLayers[key] = layer.Value;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Order a collection's chats: pinned first, then by layer (active up / context down), then by the\n // collection's MANUAL order (its SessionIds sequence), then recency. So manual drags persist and\n // layer rules group on top of them.\n public IReadOnlyList OrderCollectionChats(ArchiveCollection col, IEnumerable sessions)\n {\n var manual = new Dictionary(StringComparer.OrdinalIgnoreCase);\n for (var i = 0; i < col.SessionIds.Count; i++) manual.TryAdd(col.SessionIds[i], i);\n return sessions\n .OrderByDescending(s => s.Pinned)\n .ThenBy(s => SessionLayer(s))\n .ThenBy(s => manual.TryGetValue(s.Id, out var i) ? i : int.MaxValue)\n .ThenByDescending(s => s.UpdatedAt)\n .ToList();\n }\n\n // Move a chat within a collection's manual order by delta (negative = up). toEnd jumps to top/bottom.\n public async Task ReorderInCollectionAsync(string collectionId, string sessionId, int delta, bool toEnd = false)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n var i = col.SessionIds.FindIndex(x => string.Equals(x, sessionId, StringComparison.OrdinalIgnoreCase));\n if (i < 0) return;\n var target = toEnd ? (delta < 0 ? 0 : col.SessionIds.Count - 1) : Math.Clamp(i + delta, 0, col.SessionIds.Count - 1);\n if (target == i) return;\n col.SessionIds.RemoveAt(i);\n col.SessionIds.Insert(target, sessionId);\n await SaveAsync();\n }\n\n // ---- Tag colors ----------------------------------------------------------------------------\n // A curated palette that reads on pure-black AMOLED and coexists with the rose accent (Tailwind-400\n // family). Tags get a deterministic auto-color from this by name hash; users can override per tag.\n // Rose (#FB7185) is deliberately excluded - it's reserved for the accent / active-filter state.\n public static readonly IReadOnlyList TagPalette = new[]\n {\n \"#60A5FA\", \"#38BDF8\", \"#22D3EE\", \"#2DD4BF\", \"#34D399\",\n \"#A3E635\", \"#FBBF24\", \"#FB923C\", \"#F87171\", \"#A78BFA\"\n };\n\n private static string TagColorKey(string tag) => NormalizeTag(tag).ToLowerInvariant();\n\n // Stable across runs (string.GetHashCode is randomized in .NET, so use FNV-1a).\n private static uint Fnv1a(string s)\n {\n uint hash = 2166136261;\n foreach (var ch in s) { hash ^= ch; hash *= 16777619; }\n return hash;\n }\n\n public static string AutoTagColor(string tag)\n {\n var key = NormalizeTag(tag).ToLowerInvariant();\n if (key.Length == 0) return TagPalette[0];\n return TagPalette[(int)(Fnv1a(key) % (uint)TagPalette.Count)];\n }\n\n // The effective color for a tag: a user override if set, else the deterministic auto-color.\n public string TagColor(string tag)\n {\n var key = TagColorKey(tag);\n return Store.TagColors.TryGetValue(key, out var hex) && !string.IsNullOrWhiteSpace(hex)\n ? hex\n : AutoTagColor(tag);\n }\n\n public bool HasCustomTagColor(string tag) => Store.TagColors.ContainsKey(TagColorKey(tag));\n\n // Set (or clear, when hex is null/blank) a tag's color override; clearing returns it to auto.\n public async Task SetTagColorAsync(string tag, string? hex)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (string.IsNullOrWhiteSpace(hex)) Store.TagColors.Remove(key);\n else Store.TagColors[key] = hex.Trim();\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Generic add/remove against either tag store (ObservableCollection for chats, List for collections).\n private static bool AddTagTo(ICollection store, string tag, bool reserved)\n {\n var t = NormalizeTag(tag);\n if (t.Length == 0 || (reserved && IsReservedTag(t))) return false;\n if (store.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) return false;\n store.Add(t);\n return true;\n }\n\n private static bool RemoveTagFrom(ICollection store, string tag)\n {\n var match = store.FirstOrDefault(x => string.Equals(x, (tag ?? \"\").Trim(), StringComparison.OrdinalIgnoreCase));\n if (match is null) return false;\n store.Remove(match);\n return true;\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return ElementText(v);\n }\n\n private static string ElementText(JsonElement v) =>\n v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n\n private static string ElementString(JsonElement v) =>\n v.ValueKind == JsonValueKind.String ? v.GetString() ?? \"\" : \"\";\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? ElementString(rtProp) : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && ElementString(sidProp) is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && ElementString(midProp) is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && ElementString(forkProp) is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp) && ElementString(cwdProp) is { Length: > 0 } cw) cwd = cw;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? ElementString(typeProp) : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = Field(payload, \"message\");\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = Field(payload, \"name\");\n if (string.IsNullOrWhiteSpace(name)) name = \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = Field(payload, \"output\");\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = Field(payload, \"role\");\n if (string.IsNullOrWhiteSpace(role)) role = \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(aliases.Where(a => !string.Equals(a, id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = ElementText(argsProp);\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "f3fc0ee0e2916f6b2a24ced555d1d22c87f13db6": {"t": 1785756774.293683, "n": 119611, "h": "ec83b4a4172055bf", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 10; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n try\n {\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n }\n catch (Exception ex)\n {\n session.Messages = new ObservableCollection\n {\n new()\n {\n Id = \"load-error\",\n Role = \"assistant\",\n Timestamp = session.UpdatedAt,\n Text = $\"Could not load the full transcript from disk.\\n\\n{ex.Message}\\n\\nSource: {path}\"\n }\n };\n session.CodeBlocks = new ObservableCollection();\n session.MessageCount = session.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n TagColors = new Dictionary(Store.TagColors),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n await AddToCollectionAsync(s, cmd.project!);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var persisted = Store.Collections.TryGetValue(Slug(cmd.project!), out var collection)\n && collection.SessionIds.Contains(s.Id);\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n case \"tag\":\n case \"untag\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(op), inputId);\n var tags = (cmd.tags ?? new List()).ToList();\n if (!string.IsNullOrWhiteSpace(cmd.name)) tags.Add(cmd.name!);\n tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();\n if (tags.Count == 0) return new AgentCommandResult(false, $\"{op} needs 'tags' (array) or 'name'.\", inputId, s.Id);\n var changed = 0;\n foreach (var t in tags)\n changed += (op == \"tag\" ? AddTagTo(s.Tags, t, reserved: true) : RemoveTagFrom(s.Tags, t)) ? 1 : 0;\n if (changed > 0) { await SaveAsync(); RefreshSessions(Store.Sessions.Values); }\n var verb = op == \"tag\" ? \"Tagged\" : \"Untagged\";\n return new AgentCommandResult(true, $\"{verb} \\\"{s.DisplayTitle}\\\" ({changed} change{(changed == 1 ? \"\" : \"s\")}).\", inputId, s.Id, Persisted: changed > 0);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return await RefreshIndexedSessionAsync(s, ExplicitId(cmd), cmd.tool);\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Exact-id/self commands must not act on stale metadata. A long Claude session can keep the same\n // resumable id while new turns are appended far past the original indexed window, so refresh the\n // matched source file before filing/favoriting/naming it.\n private async Task RefreshIndexedSessionAsync(ArchiveSession existing, string? expectedId, string? tool)\n {\n try\n {\n var path = string.IsNullOrWhiteSpace(existing.SourcePath) ? \"\"\n : Path.IsPathRooted(existing.SourcePath) ? existing.SourcePath : Path.Combine(_rootPath, existing.SourcePath);\n if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return existing;\n\n var parsed = await ParseSessionAsync(path, string.IsNullOrWhiteSpace(tool) ? existing.Tool : tool!);\n if (parsed is null) return existing;\n\n var targetId = !string.IsNullOrWhiteSpace(expectedId) ? expectedId! : existing.Id;\n if (!SessionHasIdOrAlias(parsed, targetId) && !string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase))\n return existing;\n\n PreserveAppFields(existing, parsed);\n if (!string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase)) Store.Sessions.Remove(existing.Id);\n Store.Sessions[parsed.Id] = parsed;\n\n var info = new FileInfo(path);\n Store.FileStamps[path] = info.LastWriteTimeUtc.Ticks + \":\" + info.Length;\n return parsed;\n }\n catch\n {\n return existing;\n }\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n // ---- Tagging & filtering -------------------------------------------------------------------\n // Reserved tags are auto-managed (archive = \"lives in the archive\", code = \"has code blocks\") and\n // are hidden from the tag UI/filters so user tags stay clean. Everything else is a user tag.\n public static readonly IReadOnlyCollection ReservedTags =\n new HashSet(StringComparer.OrdinalIgnoreCase) { \"archive\", \"code\" };\n\n public static bool IsReservedTag(string? tag) => !string.IsNullOrWhiteSpace(tag) && ReservedTags.Contains(tag.Trim());\n\n public static string NormalizeTag(string? tag)\n {\n var t = Regex.Replace((tag ?? \"\").Trim(), \"\\\\s+\", \" \");\n return t.Length > 40 ? t[..40].Trim() : t;\n }\n\n // The user-facing tags on a chat (reserved tags removed), sorted, de-duped case-insensitively.\n public static IReadOnlyList UserTags(ArchiveSession session) =>\n session.Tags\n .Where(t => !string.IsNullOrWhiteSpace(t) && !IsReservedTag(t))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public async Task AddChatTagAsync(ArchiveSession session, string tag)\n {\n if (!AddTagTo(session.Tags, tag, reserved: true)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RemoveChatTagAsync(ArchiveSession session, string tag)\n {\n if (!RemoveTagFrom(session.Tags, tag)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task AddCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!AddTagTo(c.Tags, tag, reserved: false)) return false;\n await SaveAsync();\n return true;\n }\n\n public async Task RemoveCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!RemoveTagFrom(c.Tags, tag)) return false;\n await SaveAsync();\n return true;\n }\n\n // All user chat tags with counts (most-used first, then alpha) - drives the chat filter strip.\n public IReadOnlyList AllChatTags() =>\n Store.Sessions.Values\n .Where(s => !s.Archived)\n .SelectMany(UserTags)\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public IReadOnlyList AllCollectionTags() =>\n Store.Collections.Values\n .SelectMany(c => c.Tags.Where(t => !string.IsNullOrWhiteSpace(t)))\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n // Back-compat thin wrapper: text + an ANY/ALL include-tag filter.\n public IReadOnlyList Filter(string query, IReadOnlyCollection? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty()).ToList(), MatchAllIncludes = matchAll });\n\n private static bool SessionHasTag(ArchiveSession s, string tag) =>\n s.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n\n // The compound filter: text search, then restrict to a collection (if set), then keep chats that\n // satisfy the include set (ANY or ALL) and carry NONE of the exclude set.\n public IReadOnlyList FilterChats(ChatFilter f)\n {\n IEnumerable baseSet = string.IsNullOrWhiteSpace(f.Query)\n ? OrderedVisibleSessions(Store.Sessions.Values)\n : Search(f.Query);\n\n if (!string.IsNullOrEmpty(f.CollectionId) && Store.Collections.TryGetValue(f.CollectionId, out var col))\n {\n var ids = new HashSet(col.SessionIds, StringComparer.OrdinalIgnoreCase);\n baseSet = baseSet.Where(s => ids.Contains(s.Id));\n }\n\n return baseSet\n .Where(s => f.IncludeTags.Count == 0\n || (f.MatchAllIncludes\n ? f.IncludeTags.All(t => SessionHasTag(s, t))\n : f.IncludeTags.Any(t => SessionHasTag(s, t))))\n .Where(s => f.ExcludeTags.Count == 0 || !f.ExcludeTags.Any(t => SessionHasTag(s, t)))\n .ToList();\n }\n\n // ---- Tag colors ----------------------------------------------------------------------------\n // A curated palette that reads on pure-black AMOLED and coexists with the rose accent (Tailwind-400\n // family). Tags get a deterministic auto-color from this by name hash; users can override per tag.\n // Rose (#FB7185) is deliberately excluded - it's reserved for the accent / active-filter state.\n public static readonly IReadOnlyList TagPalette = new[]\n {\n \"#60A5FA\", \"#38BDF8\", \"#22D3EE\", \"#2DD4BF\", \"#34D399\",\n \"#A3E635\", \"#FBBF24\", \"#FB923C\", \"#F87171\", \"#A78BFA\"\n };\n\n private static string TagColorKey(string tag) => NormalizeTag(tag).ToLowerInvariant();\n\n // Stable across runs (string.GetHashCode is randomized in .NET, so use FNV-1a).\n private static uint Fnv1a(string s)\n {\n uint hash = 2166136261;\n foreach (var ch in s) { hash ^= ch; hash *= 16777619; }\n return hash;\n }\n\n public static string AutoTagColor(string tag)\n {\n var key = NormalizeTag(tag).ToLowerInvariant();\n if (key.Length == 0) return TagPalette[0];\n return TagPalette[(int)(Fnv1a(key) % (uint)TagPalette.Count)];\n }\n\n // The effective color for a tag: a user override if set, else the deterministic auto-color.\n public string TagColor(string tag)\n {\n var key = TagColorKey(tag);\n return Store.TagColors.TryGetValue(key, out var hex) && !string.IsNullOrWhiteSpace(hex)\n ? hex\n : AutoTagColor(tag);\n }\n\n public bool HasCustomTagColor(string tag) => Store.TagColors.ContainsKey(TagColorKey(tag));\n\n // Set (or clear, when hex is null/blank) a tag's color override; clearing returns it to auto.\n public async Task SetTagColorAsync(string tag, string? hex)\n {\n var key = TagColorKey(tag);\n if (key.Length == 0) return;\n if (string.IsNullOrWhiteSpace(hex)) Store.TagColors.Remove(key);\n else Store.TagColors[key] = hex.Trim();\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n // Generic add/remove against either tag store (ObservableCollection for chats, List for collections).\n private static bool AddTagTo(ICollection store, string tag, bool reserved)\n {\n var t = NormalizeTag(tag);\n if (t.Length == 0 || (reserved && IsReservedTag(t))) return false;\n if (store.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) return false;\n store.Add(t);\n return true;\n }\n\n private static bool RemoveTagFrom(ICollection store, string tag)\n {\n var match = store.FirstOrDefault(x => string.Equals(x, (tag ?? \"\").Trim(), StringComparison.OrdinalIgnoreCase));\n if (match is null) return false;\n store.Remove(match);\n return true;\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return ElementText(v);\n }\n\n private static string ElementText(JsonElement v) =>\n v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n\n private static string ElementString(JsonElement v) =>\n v.ValueKind == JsonValueKind.String ? v.GetString() ?? \"\" : \"\";\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? ElementString(rtProp) : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && ElementString(sidProp) is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && ElementString(midProp) is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && ElementString(forkProp) is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp) && ElementString(cwdProp) is { Length: > 0 } cw) cwd = cw;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? ElementString(typeProp) : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = Field(payload, \"message\");\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = Field(payload, \"name\");\n if (string.IsNullOrWhiteSpace(name)) name = \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = Field(payload, \"output\");\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = Field(payload, \"role\");\n if (string.IsNullOrWhiteSpace(role)) role = \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(aliases.Where(a => !string.Equals(a, id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = ElementText(argsProp);\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "d5022a55d956d859a580279dd72c8a5d6d49e7c7": {"t": 1785756799.6909742, "n": 116585, "h": "b4d7e8a1e01b0935", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 10; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n try\n {\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n }\n catch (Exception ex)\n {\n session.Messages = new ObservableCollection\n {\n new()\n {\n Id = \"load-error\",\n Role = \"assistant\",\n Timestamp = session.UpdatedAt,\n Text = $\"Could not load the full transcript from disk.\\n\\n{ex.Message}\\n\\nSource: {path}\"\n }\n };\n session.CodeBlocks = new ObservableCollection();\n session.MessageCount = session.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n await AddToCollectionAsync(s, cmd.project!);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var persisted = Store.Collections.TryGetValue(Slug(cmd.project!), out var collection)\n && collection.SessionIds.Contains(s.Id);\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n case \"tag\":\n case \"untag\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(op), inputId);\n var tags = (cmd.tags ?? new List()).ToList();\n if (!string.IsNullOrWhiteSpace(cmd.name)) tags.Add(cmd.name!);\n tags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList();\n if (tags.Count == 0) return new AgentCommandResult(false, $\"{op} needs 'tags' (array) or 'name'.\", inputId, s.Id);\n var changed = 0;\n foreach (var t in tags)\n changed += (op == \"tag\" ? AddTagTo(s.Tags, t, reserved: true) : RemoveTagFrom(s.Tags, t)) ? 1 : 0;\n if (changed > 0) { await SaveAsync(); RefreshSessions(Store.Sessions.Values); }\n var verb = op == \"tag\" ? \"Tagged\" : \"Untagged\";\n return new AgentCommandResult(true, $\"{verb} \\\"{s.DisplayTitle}\\\" ({changed} change{(changed == 1 ? \"\" : \"s\")}).\", inputId, s.Id, Persisted: changed > 0);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return await RefreshIndexedSessionAsync(s, ExplicitId(cmd), cmd.tool);\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Exact-id/self commands must not act on stale metadata. A long Claude session can keep the same\n // resumable id while new turns are appended far past the original indexed window, so refresh the\n // matched source file before filing/favoriting/naming it.\n private async Task RefreshIndexedSessionAsync(ArchiveSession existing, string? expectedId, string? tool)\n {\n try\n {\n var path = string.IsNullOrWhiteSpace(existing.SourcePath) ? \"\"\n : Path.IsPathRooted(existing.SourcePath) ? existing.SourcePath : Path.Combine(_rootPath, existing.SourcePath);\n if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return existing;\n\n var parsed = await ParseSessionAsync(path, string.IsNullOrWhiteSpace(tool) ? existing.Tool : tool!);\n if (parsed is null) return existing;\n\n var targetId = !string.IsNullOrWhiteSpace(expectedId) ? expectedId! : existing.Id;\n if (!SessionHasIdOrAlias(parsed, targetId) && !string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase))\n return existing;\n\n PreserveAppFields(existing, parsed);\n if (!string.Equals(parsed.Id, existing.Id, StringComparison.OrdinalIgnoreCase)) Store.Sessions.Remove(existing.Id);\n Store.Sessions[parsed.Id] = parsed;\n\n var info = new FileInfo(path);\n Store.FileStamps[path] = info.LastWriteTimeUtc.Ticks + \":\" + info.Length;\n return parsed;\n }\n catch\n {\n return existing;\n }\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n // ---- Tagging & filtering -------------------------------------------------------------------\n // Reserved tags are auto-managed (archive = \"lives in the archive\", code = \"has code blocks\") and\n // are hidden from the tag UI/filters so user tags stay clean. Everything else is a user tag.\n public static readonly IReadOnlyCollection ReservedTags =\n new HashSet(StringComparer.OrdinalIgnoreCase) { \"archive\", \"code\" };\n\n public static bool IsReservedTag(string? tag) => !string.IsNullOrWhiteSpace(tag) && ReservedTags.Contains(tag.Trim());\n\n public static string NormalizeTag(string? tag)\n {\n var t = Regex.Replace((tag ?? \"\").Trim(), \"\\\\s+\", \" \");\n return t.Length > 40 ? t[..40].Trim() : t;\n }\n\n // The user-facing tags on a chat (reserved tags removed), sorted, de-duped case-insensitively.\n public static IReadOnlyList UserTags(ArchiveSession session) =>\n session.Tags\n .Where(t => !string.IsNullOrWhiteSpace(t) && !IsReservedTag(t))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public async Task AddChatTagAsync(ArchiveSession session, string tag)\n {\n if (!AddTagTo(session.Tags, tag, reserved: true)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RemoveChatTagAsync(ArchiveSession session, string tag)\n {\n if (!RemoveTagFrom(session.Tags, tag)) return false;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task AddCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!AddTagTo(c.Tags, tag, reserved: false)) return false;\n await SaveAsync();\n return true;\n }\n\n public async Task RemoveCollectionTagAsync(string collectionId, string tag)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var c)) return false;\n if (!RemoveTagFrom(c.Tags, tag)) return false;\n await SaveAsync();\n return true;\n }\n\n // All user chat tags with counts (most-used first, then alpha) - drives the chat filter strip.\n public IReadOnlyList AllChatTags() =>\n Store.Sessions.Values\n .Where(s => !s.Archived)\n .SelectMany(UserTags)\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n public IReadOnlyList AllCollectionTags() =>\n Store.Collections.Values\n .SelectMany(c => c.Tags.Where(t => !string.IsNullOrWhiteSpace(t)))\n .GroupBy(t => t, StringComparer.OrdinalIgnoreCase)\n .Select(g => new TagCount(g.Key, g.Count()))\n .OrderByDescending(x => x.Count).ThenBy(x => x.Tag, StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n // Text search (if any) intersected with a tag filter. matchAll=false => chats with ANY of the tags.\n public IReadOnlyList Filter(string query, IReadOnlyCollection? tags, bool matchAll = false)\n {\n IEnumerable baseSet = string.IsNullOrWhiteSpace(query)\n ? OrderedVisibleSessions(Store.Sessions.Values)\n : Search(query);\n if (tags is null || tags.Count == 0) return baseSet.ToList();\n bool Has(ArchiveSession s, string tag) => s.Tags.Any(x => string.Equals(x, tag, StringComparison.OrdinalIgnoreCase));\n return baseSet\n .Where(s => matchAll ? tags.All(t => Has(s, t)) : tags.Any(t => Has(s, t)))\n .ToList();\n }\n\n // Generic add/remove against either tag store (ObservableCollection for chats, List for collections).\n private static bool AddTagTo(ICollection store, string tag, bool reserved)\n {\n var t = NormalizeTag(tag);\n if (t.Length == 0 || (reserved && IsReservedTag(t))) return false;\n if (store.Any(x => string.Equals(x, t, StringComparison.OrdinalIgnoreCase))) return false;\n store.Add(t);\n return true;\n }\n\n private static bool RemoveTagFrom(ICollection store, string tag)\n {\n var match = store.FirstOrDefault(x => string.Equals(x, (tag ?? \"\").Trim(), StringComparison.OrdinalIgnoreCase));\n if (match is null) return false;\n store.Remove(match);\n return true;\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return ElementText(v);\n }\n\n private static string ElementText(JsonElement v) =>\n v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n\n private static string ElementString(JsonElement v) =>\n v.ValueKind == JsonValueKind.String ? v.GetString() ?? \"\" : \"\";\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? ElementString(rtProp) : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && ElementString(sidProp) is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && ElementString(midProp) is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && ElementString(forkProp) is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp) && ElementString(cwdProp) is { Length: > 0 } cw) cwd = cw;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? ElementString(typeProp) : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = Field(payload, \"message\");\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = Field(payload, \"name\");\n if (string.IsNullOrWhiteSpace(name)) name = \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = Field(payload, \"output\");\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = Field(payload, \"role\");\n if (string.IsNullOrWhiteSpace(role)) role = \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(aliases.Where(a => !string.Equals(a, id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = ElementText(argsProp);\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "f7420434f1f242e91edb87e1072f18c37d1e1c2a": {"t": 1785756801.6106973, "n": 108273, "h": "c5f86e0e9313d0a8", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 8; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int ClaudeTailBytes = 32 * 1024 * 1024;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[^SearchTextCap..];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n await AddToCollectionAsync(s, cmd.project!);\n var named = ApplyOptionalName(s, cmd); // optional app-local name in the same call\n if (named) await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n var persisted = Store.Collections.TryGetValue(Slug(cmd.project!), out var collection)\n && collection.SessionIds.Contains(s.Id);\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".{(named ? \" Named it in the app.\" : \"\")}\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!ApplyOptionalName(s, cmd) && string.IsNullOrWhiteSpace(cmd.canonicalName))\n return new AgentCommandResult(false, \"rename/setName needs a 'name' (app-only) and/or 'canonicalName'.\", inputId, s.Id);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"setname\" or \"name\" or \"label\" or \"setlabel\" => \"rename\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n // Optional app-LOCAL chat name (cmd.name, falling back to cmd.localName). Never touches the\n // agent's own global title - that's canonicalName's job. Returns true if a name was applied.\n private bool ApplyOptionalName(ArchiveSession session, AgentCommand cmd)\n {\n var name = !string.IsNullOrWhiteSpace(cmd.name) ? cmd.name : cmd.localName;\n if (string.IsNullOrWhiteSpace(name)) return false;\n session.CustomTitle = CleanTitle(name!);\n return true;\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return s;\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? rtProp.GetString() : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && sidProp.GetString() is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && midProp.GetString() is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && forkProp.GetString() is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = payload.TryGetProperty(\"name\", out var np) ? np.GetString() ?? \"tool\" : \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = payload.TryGetProperty(\"output\", out var op) ? op.GetString() ?? \"\" : \"\";\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(aliases.Where(a => !string.Equals(a, id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n var hitLineCap = false;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) { hitLineCap = true; break; }\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n var titleSeed = FirstMeaningfulUserText(messages);\n if (hitLineCap)\n {\n var tail = ParseClaudeTail(filePath, info);\n if (tail.messages.Count > 0)\n {\n messages = tail.messages;\n codeBlocks = tail.codeBlocks;\n if (!string.IsNullOrWhiteSpace(tail.cwd)) cwd = tail.cwd;\n if (!string.IsNullOrWhiteSpace(tail.summary)) summary = tail.summary;\n if (!string.IsNullOrWhiteSpace(tail.updated)) updated = tail.updated;\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n titleSeed ?? FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection messages, ObservableCollection codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal);\n var cwd = \"\";\n var summary = \"\";\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, ClaudeTailBytes);\n if (n <= 0) return (messages, codeBlocks, cwd, summary, updated);\n var offset = fs.Length - n;\n fs.Seek(offset, SeekOrigin.Begin);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n var lines = Encoding.UTF8.GetString(buf, 0, read).Replace(\"\\r\\n\", \"\\n\").Split('\\n');\n var usable = lines.Skip(offset > 0 ? 1 : 0).ToList(); // first line is partial when starting mid-file\n if (usable.Count > MaxLinesPerSession) usable = usable.Skip(usable.Count - MaxLinesPerSession).ToList();\n\n foreach (var line in usable)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the tail window.\n }\n }\n }\n }\n catch\n {\n // Tail parsing is best-effort; the head parse remains usable if this fails.\n }\n\n return (messages, codeBlocks, cwd, summary, updated);\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = argsProp.GetString() ?? \"\";\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "01d9c0e8ee94da6a1454953665cb107c4b1dafc2": {"t": 1785756803.2418709, "n": 103352, "h": "bad4a4479edaaa5c", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 7; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n var persisted = Store.Collections.TryGetValue(Slug(cmd.project!), out var collection)\n && collection.SessionIds.Contains(s.Id);\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n case \"bump\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"bump\"), inputId);\n var native = await BumpSessionAsync(s);\n var where = string.Equals(s.Tool, \"codex\", StringComparison.OrdinalIgnoreCase) ? \"codex resume\" : \"Claude's recent chats\";\n return new AgentCommandResult(\n true,\n native ? $\"Bumped \\\"{s.DisplayTitle}\\\" to the top of {where}.\" : $\"Bumped \\\"{s.DisplayTitle}\\\" in the app (native list unchanged — chat not found in {where}).\",\n inputId, s.Id, Persisted: native);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return s;\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n // \"Bump\": float a chat to the top of Codex/Claude's OWN resume picker without sending a message.\n // Each picker sorts by recency, so we just refresh the recency signal it reads:\n // - Codex orders by threads.updated_at_ms in ~/.codex/state_5.sqlite (keyed by the thread id).\n // - Claude lists transcript .jsonl files by file mtime.\n // Also floats the chat to the top of THIS app's list. Returns true if the native signal was updated.\n public async Task BumpSessionAsync(ArchiveSession session)\n {\n var nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();\n var native = await Task.Run(() =>\n {\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n {\n var dbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n var ids = new List { session.Id };\n ids.AddRange(session.Aliases);\n return BumpCodexThreadUpdatedAt(dbPath, ids, nowMs) > 0;\n }\n try\n {\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\"\n : Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length > 0 && File.Exists(path)) { File.SetLastWriteTimeUtc(path, DateTime.UtcNow); return true; }\n }\n catch { }\n return false;\n });\n\n session.UpdatedAt = DateTime.UtcNow.ToString(\"O\"); // top of our list too\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return native;\n }\n\n // Set updated_at_ms (+ updated_at seconds) = now for the given thread ids in Codex's state DB,\n // so `codex resume` shows the chat first. Returns rows changed. Takes an explicit path for tests.\n public static int BumpCodexThreadUpdatedAt(string dbPath, IEnumerable ids, long nowMs)\n {\n if (!File.Exists(dbPath)) return 0;\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n var changed = 0;\n foreach (var id in ids.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase))\n {\n using var cmd = connection.CreateCommand();\n cmd.CommandText = \"update threads set updated_at_ms = $ms, updated_at = $s where id = $id\";\n cmd.Parameters.AddWithValue(\"$ms\", nowMs);\n cmd.Parameters.AddWithValue(\"$s\", nowMs / 1000);\n cmd.Parameters.AddWithValue(\"$id\", id);\n changed += cmd.ExecuteNonQuery();\n }\n return changed;\n }\n catch { return 0; }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? rtProp.GetString() : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && sidProp.GetString() is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && midProp.GetString() is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && forkProp.GetString() is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = payload.TryGetProperty(\"name\", out var np) ? np.GetString() ?? \"tool\" : \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = payload.TryGetProperty(\"output\", out var op) ? op.GetString() ?? \"\" : \"\";\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(aliases.Where(a => !string.Equals(a, id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = argsProp.GetString() ?? \"\";\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "101f10f69e87fa63c4cf8ea4e17bbf6b2eeca6e3": {"t": 1785756805.2215486, "n": 99490, "h": "21a1d98fe6a7ed5e", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 7; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n // Force a fresh re-parse from disk (the live tail of an open chat as the agent keeps writing it).\n public async Task ReloadContentAsync(ArchiveSession session)\n {\n session.ContentLoaded = false;\n await EnsureContentAsync(session);\n }\n\n // The last-write time of a session's source transcript, for cheap \"did it change?\" polling.\n public DateTime SourceWriteTimeUtc(ArchiveSession session) => SourceFileWriteTimeUtc(session);\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n var op = NormalizeAgentOp(cmd.op);\n switch (op)\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"), inputId);\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\", inputId, s.Id, Persisted: true);\n }\n\n case \"addselftoproject\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"), inputId, Project: cmd.project);\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n var persisted = Store.Collections.TryGetValue(Slug(cmd.project!), out var collection)\n && collection.SessionIds.Contains(s.Id);\n return new AgentCommandResult(\n true,\n $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\",\n inputId,\n s.Id,\n cmd.project,\n persisted);\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n var inputId = ExplicitId(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"), inputId);\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\", inputId, s.Id, Persisted: true);\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n public static string NormalizeAgentOp(string? op)\n {\n var value = (op ?? \"\").Trim().ToLowerInvariant();\n return value switch\n {\n \"addtoproject\" or \"addtocollection\" or \"addselftoproject\" or \"addselftocollection\" => \"addselftoproject\",\n \"pin\" => \"pin\",\n _ => value\n };\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id.Trim();\n if (!string.IsNullOrWhiteSpace(cmd.target) && !IsSelfTarget(cmd.target)) return cmd.target.Trim();\n return null;\n }\n\n private static bool IsSelfTarget(string? target) =>\n string.Equals(target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(target, \"latest\", StringComparison.OrdinalIgnoreCase);\n\n private static bool IsSelfOp(AgentCommand cmd) =>\n NormalizeAgentOp(cmd.op) is \"addselftoproject\" or \"addselftocollection\";\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return s;\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!, cmd.tool);\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id, string? tool)\n {\n if (ResolveSessionByIdOrAlias(id, tool) is { } existing) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (!string.IsNullOrWhiteSpace(tool) && !string.Equals(src.Tool, tool, StringComparison.OrdinalIgnoreCase)) continue;\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !SessionHasIdOrAlias(parsed, id)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its env-provided runtime id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your runtime session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. The app resolves that id to its stored chat key. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its runtime session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID). The store may key a\n // resumed/forked chat by another canonical id, so id matching accepts exact aliases from the\n // transcript header/path. So:\n // 1. If an id is supplied, it must match an indexed session id/alias, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = ExplicitId(cmd);\n if (!string.IsNullOrWhiteSpace(explicitId))\n return ResolveSessionByIdOrAlias(explicitId!, cmd.tool); // strong id/alias or nothing\n\n var isSelf = IsSelfTarget(cmd.target) || IsSelfOp(cmd);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n private ArchiveSession? ResolveSessionByIdOrAlias(string id, string? tool)\n {\n var input = id.Trim();\n if (Store.Sessions.TryGetValue(input, out var byKey) && ToolMatches(byKey, tool)) return byKey;\n\n var exact = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => string.Equals(s.Id, input, StringComparison.OrdinalIgnoreCase))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n if (exact.Count == 1) return exact[0];\n if (exact.Count > 1) return null;\n\n var aliases = Store.Sessions.Values\n .Where(s => ToolMatches(s, tool))\n .Where(s => SessionHasIdOrAlias(s, input))\n .GroupBy(s => s.Id, StringComparer.OrdinalIgnoreCase)\n .Select(g => g.First())\n .ToList();\n return aliases.Count == 1 ? aliases[0] : null;\n }\n\n private static bool ToolMatches(ArchiveSession session, string? tool) =>\n string.IsNullOrWhiteSpace(tool) || string.Equals(session.Tool, tool, StringComparison.OrdinalIgnoreCase);\n\n private static bool SessionHasIdOrAlias(ArchiveSession session, string id)\n {\n if (string.Equals(session.Id, id, StringComparison.OrdinalIgnoreCase)) return true;\n if (session.Aliases.Any(a => string.Equals(a, id, StringComparison.OrdinalIgnoreCase))) return true;\n return IsAliasToken(id)\n && !string.IsNullOrWhiteSpace(session.SourcePath)\n && session.SourcePath.Contains(id, StringComparison.OrdinalIgnoreCase);\n }\n\n private static bool IsAliasToken(string id) => id.Length >= 8 && IsResumableId(id);\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var alias in existing.Aliases)\n {\n if (!incoming.Aliases.Contains(alias, StringComparer.OrdinalIgnoreCase)) incoming.Aliases.Add(alias);\n }\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var fallbackMessages = new ObservableCollection();\n var fallbackCodeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet(StringComparer.Ordinal);\n var fallbackSeen = new HashSet(StringComparer.Ordinal);\n var sawEventMessages = false;\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n ArchiveMessage? lastTool = null; // the function_call awaiting its function_call_output\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? rtProp.GetString() : null;\n if (rootType == \"session_meta\")\n {\n var sessionMetaAssigned = false;\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && sidProp.GetString() is { Length: > 0 } sid)\n {\n id = sid;\n sessionMetaAssigned = true;\n AddAlias(aliases, sid);\n }\n if (payload.TryGetProperty(\"id\", out var midProp) && midProp.GetString() is { Length: > 0 } mid)\n {\n if (!sessionMetaAssigned) id = mid;\n AddAlias(aliases, mid);\n }\n if (payload.TryGetProperty(\"forked_from_id\", out var forkProp) && forkProp.GetString() is { Length: > 0 } fork)\n {\n AddAlias(aliases, fork);\n }\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n var pType = payload.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (rootType == \"event_msg\")\n {\n // Clean turn-level text. We take user/assistant text ONLY from event_msg; the\n // parallel response_item \"message\" lines are the same text again (different\n // timestamps) and are what made every turn show up twice.\n if (pType is \"user_message\" or \"agent_message\")\n {\n sawEventMessages = true;\n var role = pType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n else if (rootType == \"response_item\")\n {\n // Tool calls become one compact, collapsible step (command + its output), not a\n // wall of separate bubbles. response_item \"message\"/\"reasoning\" are skipped.\n if (pType == \"function_call\")\n {\n var name = payload.TryGetProperty(\"name\", out var np) ? np.GetString() ?? \"tool\" : \"tool\";\n lastTool = AddToolStep(messages, name, ExtractToolCommand(payload), timestamp);\n }\n else if (pType == \"function_call_output\" && lastTool is not null)\n {\n var output = payload.TryGetProperty(\"output\", out var op) ? op.GetString() ?? \"\" : \"\";\n lastTool.ToolOutput = CapDisplayText(output, 4000);\n }\n else if (pType == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (IsIndexedRole(role)) AddMessage(fallbackMessages, fallbackCodeBlocks, role, ExtractContent(payload), timestamp, fallbackSeen);\n }\n }\n }\n }\n }\n\n if (!sawEventMessages && fallbackMessages.Count > 0)\n {\n var merged = fallbackMessages\n .Concat(messages.Where(m => m.EffectiveKind == \"tool\"))\n .OrderBy(m => m.Timestamp, StringComparer.Ordinal)\n .ToList();\n messages = new ObservableCollection(merged);\n codeBlocks = new ObservableCollection(fallbackCodeBlocks.Concat(merged.SelectMany(m => m.CodeBlocks)));\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n Aliases = new ObservableCollection(aliases.Where(a => !string.Equals(a, id, StringComparison.OrdinalIgnoreCase))),\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet aliases, string? value)\n {\n if (string.IsNullOrWhiteSpace(value)) return;\n var trimmed = value.Trim();\n if (IsAliasToken(trimmed)) aliases.Add(trimmed);\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var toolUseById = new Dictionary(StringComparer.Ordinal); // tool_use id -> step (output attached from the later tool_result)\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n ProcessClaudeMessage(message, role, timestamp, messages, codeBlocks, seen, toolUseById);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n // Parse one Claude message into clean chat entries: text becomes a bubble; each tool_use becomes a\n // compact tool step (with its output attached from the later tool_result); a message that is only a\n // tool_result is NOT shown as a bubble (its output rides on the tool step). This is what turns a\n // wall of tool dumps into a readable conversation.\n private static void ProcessClaudeMessage(JsonElement message, string role, string timestamp,\n ObservableCollection messages, ObservableCollection codeBlocks,\n HashSet seen, Dictionary toolUseById)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return;\n if (content.ValueKind == JsonValueKind.String)\n {\n AddMessage(messages, codeBlocks, role, content.GetString() ?? \"\", timestamp, seen);\n return;\n }\n if (content.ValueKind != JsonValueKind.Array) return;\n\n var textSb = new StringBuilder();\n void FlushText()\n {\n if (textSb.Length == 0) return;\n AddMessage(messages, codeBlocks, role, textSb.ToString(), timestamp, seen);\n textSb.Clear();\n }\n\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { textSb.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var t))\n {\n textSb.AppendLine(t.GetString());\n }\n else if (itemType == \"tool_use\")\n {\n FlushText(); // keep order: any text before the call shows first\n var name = item.TryGetProperty(\"name\", out var nm) ? nm.GetString() ?? \"tool\" : \"tool\";\n var step = AddToolStep(messages, name, ClaudeToolCommand(item), timestamp);\n if (item.TryGetProperty(\"id\", out var idp) && idp.GetString() is { Length: > 0 } tid) toolUseById[tid] = step;\n }\n else if (itemType == \"tool_result\")\n {\n var outText = ExtractToolResultText(item);\n if (item.TryGetProperty(\"tool_use_id\", out var tup) && tup.GetString() is { Length: > 0 } tuid\n && toolUseById.TryGetValue(tuid, out var step))\n step.ToolOutput = CapDisplayText(outText, 4000);\n // a tool_result with no matching call is dropped (it's noise, not a chat turn)\n }\n }\n FlushText();\n }\n\n // A short label for a Claude tool call from its input (the command / file / pattern it acted on).\n private static string ClaudeToolCommand(JsonElement toolUse)\n {\n if (!toolUse.TryGetProperty(\"input\", out var input) || input.ValueKind != JsonValueKind.Object) return \"\";\n foreach (var key in new[] { \"command\", \"file_path\", \"path\", \"pattern\", \"url\", \"query\", \"prompt\" })\n if (input.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s)\n return s;\n return CapDisplayText(input.ToString(), 400);\n }\n\n private static string ExtractToolResultText(JsonElement item)\n {\n if (!item.TryGetProperty(\"content\", out var c)) return \"\";\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? \"\";\n if (c.ValueKind != JsonValueKind.Array) return \"\";\n var sb = new StringBuilder();\n foreach (var b in c.EnumerateArray())\n if (b.ValueKind == JsonValueKind.Object && b.TryGetProperty(\"text\", out var t)) sb.AppendLine(t.GetString());\n return sb.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // A consolidated tool step: one command + (later) its output, rendered as a single collapsible line\n // instead of separate bubbles.\n private static ArchiveMessage AddToolStep(ObservableCollection messages, string name, string command, string timestamp)\n {\n var step = new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = \"tool\",\n Kind = \"tool\",\n ToolName = FriendlyToolName(name),\n Text = CapDisplayText(command, 1200),\n Timestamp = timestamp\n };\n messages.Add(step);\n return step;\n }\n\n private static string FriendlyToolName(string name) => name switch\n {\n \"shell_command\" or \"shell\" or \"exec_command\" or \"local_shell\" => \"shell\",\n \"apply_patch\" or \"edit_file\" or \"write_file\" => \"edit\",\n \"read_file\" or \"view\" => \"read\",\n \"update_plan\" => \"plan\",\n _ => string.IsNullOrWhiteSpace(name) ? \"tool\" : name\n };\n\n // Codex tool args arrive as a JSON string, e.g. {\"command\":[\"bash\",\"-lc\",\"...\"]} or {\"path\":\"...\"}.\n private static string ExtractToolCommand(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"arguments\", out var argsProp)) return \"\";\n var raw = argsProp.GetString() ?? \"\";\n try\n {\n using var d = JsonDocument.Parse(raw);\n var r = d.RootElement;\n if (r.TryGetProperty(\"command\", out var c))\n {\n if (c.ValueKind == JsonValueKind.Array)\n return string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()));\n if (c.ValueKind == JsonValueKind.String) return c.GetString() ?? raw;\n }\n if (r.TryGetProperty(\"path\", out var p) && p.ValueKind == JsonValueKind.String) return p.GetString() ?? raw;\n }\n catch { }\n return raw;\n }\n\n private static string CapDisplayText(string s, int max) =>\n string.IsNullOrEmpty(s) || s.Length <= max ? s : s[..max] + \"\\n…(truncated)\";\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "31a957fe79b4f6205b7bc7d401081d3c578d7f10": {"t": 1785756806.885418, "n": 86707, "h": "25415eb84e901b94", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 6; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"favorite\"));\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = await ResolveOrIndexTargetAsync(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"addToCollection\"));\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = await ResolveOrIndexTargetAsync(cmd);\n if (s is null) return new AgentCommandResult(false, ResolveFailureHelp(\"rename\"));\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // The id an agent supplied for a per-chat op, if any (cmd.id, or cmd.target when it is a concrete id).\n private static string? ExplicitId(AgentCommand cmd)\n {\n if (!string.IsNullOrWhiteSpace(cmd.id)) return cmd.id;\n if (!string.IsNullOrWhiteSpace(cmd.target) && cmd.target != \"self\" && cmd.target != \"latest\") return cmd.target;\n return null;\n }\n\n // Resolve the target session, and if an exact id was given but isn't in the store yet, index just\n // that one file from disk (a brand-new session adding itself before a full sync has seen it).\n private async Task ResolveOrIndexTargetAsync(AgentCommand cmd)\n {\n var s = ResolveTargetSession(cmd);\n if (s is not null) return s;\n var id = ExplicitId(cmd);\n return string.IsNullOrWhiteSpace(id) ? null : await EnsureSessionIndexedAsync(id!);\n }\n\n // Find and index the single transcript whose id matches, across the configured sources, so an\n // exact-id command works for a session the store hasn't scanned yet. Returns null if no such file\n // exists (then the caller fails closed - never a different chat).\n private async Task EnsureSessionIndexedAsync(string id)\n {\n if (Store.Sessions.TryGetValue(id, out var existing)) return existing;\n var sources = Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n foreach (var src in sources)\n {\n if (string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n string? file = null;\n try { file = Directory.EnumerateFiles(src.Root, \"*\" + id + \"*.jsonl\", SearchOption.AllDirectories).FirstOrDefault(); }\n catch { }\n if (file is null) continue;\n var parsed = await ParseSessionAsync(file, src.Tool);\n if (parsed is null || !string.Equals(parsed.Id, id, StringComparison.OrdinalIgnoreCase)) continue;\n Store.Sessions[parsed.Id] = parsed;\n await SaveAsync();\n return parsed;\n }\n return null;\n }\n\n // Shown to an agent when nothing matched - steers it to its exact env-provided id (fail closed).\n private static string ResolveFailureHelp(string op) =>\n $\"Could not identify your chat for '{op}'. Send your EXACT session id: Codex uses the CODEX_THREAD_ID \" +\n \"environment variable, Claude uses CLAUDE_CODE_SESSION_ID. Example: \" +\n $\"{{\\\"op\\\":\\\"{op}\\\",\\\"project\\\":\\\"...\\\",\\\"id\\\":\\\"\\\",\\\"tool\\\":\\\"codex\\\"}}. \" +\n \"(target:\\\"self\\\" only matches an already-indexed chat in the exact same folder + tool, and never guesses another chat.)\";\n\n // FAIL CLOSED. An agent's only strong identity is its exact session id, which it gets from its\n // environment (Codex: CODEX_THREAD_ID, Claude: CLAUDE_CODE_SESSION_ID) - the same value this app\n // keys sessions by. So:\n // 1. If an id is supplied, it must match an indexed session EXACTLY, or we return null. We never\n // \"fall through\" to a heuristic - guessing is what filed a random old chat before.\n // 2. \"self\"/\"latest\" is a fallback only, and only with BOTH a tool and a cwd; it matches within\n // that exact workspace+tool. There is NO global \"most recent\" fallback.\n // Returning null is correct - the caller turns it into an error so the agent re-sends an exact id,\n // rather than the app silently picking the wrong conversation.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId))\n return Store.Sessions.TryGetValue(explicitId!, out var byId) ? byId : null; // exact or nothing\n\n var isSelf = string.Equals(cmd.target, \"self\", StringComparison.OrdinalIgnoreCase)\n || string.Equals(cmd.target, \"latest\", StringComparison.OrdinalIgnoreCase);\n if (!isSelf) return null;\n if (string.IsNullOrWhiteSpace(cmd.tool) || string.IsNullOrWhiteSpace(cmd.cwd)) return null; // self needs tool+cwd\n\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .Where(s => NormalizePath(s.Workspace) == norm)\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault(); // null if nothing in that exact workspace+tool\n }\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n // The session id (== Codex thread id, i.e. CODEX_THREAD_ID, which agents pass as \"id\")\n // comes ONLY from the session_meta header line. Later payload lines carry per-item\n // \"id\" values (rs_... response ids) that must NOT overwrite it - that mis-keyed every\n // session and is why exact-id self-add could never match.\n var rootType = root.TryGetProperty(\"type\", out var rtProp) ? rtProp.GetString() : null;\n if (rootType == \"session_meta\")\n {\n if (payload.TryGetProperty(\"session_id\", out var sidProp) && sidProp.GetString() is { Length: > 0 } sid) id = sid;\n else if (payload.TryGetProperty(\"id\", out var midProp) && midProp.GetString() is { Length: > 0 } mid) id = mid;\n }\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "daf63ffa7069781bddca0978ac490adfef869ced": {"t": 1785756808.6449583, "n": 83213, "h": "9e99e0dcbae545a0", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 5; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets. Priority:\n // 1. an explicit session id (cmd.id, or cmd.target when it's a concrete id);\n // 2. \"self\"/\"latest\"/no target -> the session whose SOURCE TRANSCRIPT was most recently written.\n // The agent issuing the command is, at that moment, the one actively appending to its own\n // transcript, so file mtime is the reliable \"which chat is live\" signal. We prefer chats in the\n // agent's working directory (cwd) to disambiguate a folder with many chats, and fall back to all\n // chats if cwd matches nothing (e.g. the agent didn't send one, or it's a sub/parent dir).\n // NOTE: parsed UpdatedAt is NOT used to pick — it reflects an in-transcript timestamp that can be\n // stale or wrong (a long chat can report a months-old time), which used to grab the wrong sibling.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n IEnumerable candidates = Store.Sessions.Values;\n if (!string.IsNullOrWhiteSpace(cmd.tool))\n candidates = candidates.Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase));\n\n if (!string.IsNullOrWhiteSpace(cmd.cwd))\n {\n var norm = NormalizePath(cmd.cwd!);\n var scoped = candidates.Where(s => NormalizePath(s.Workspace) == norm).ToList();\n if (scoped.Count > 0) candidates = scoped; // else fall through to the global newest-written chat\n }\n\n return candidates\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n // A prompt you paste into a fresh Claude/Codex agent to pick this chat back up. The source\n // transcript is treated as AUTHORITATIVE and the snippets as a mere preview, so the agent reads\n // the full file and reconstructs the real task instead of acting on a truncated excerpt.\n private string ResumePrompt(ArchiveSession session)\n {\n var preview = string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m =>\n {\n var clean = Regex.Replace(m.Text ?? \"\", \"\\\\s+\", \" \").Trim();\n return $\"- {m.Role}: {clean[..Math.Min(260, clean.Length)]}\";\n }));\n\n return\n \"Continue this archived work from local context.\\n\\n\" +\n \"Your first step:\\n\" +\n $\"Read the archived transcript at:\\n{session.SourcePath}\\n\\n\" +\n $\"Workspace:\\n{session.Workspace}\\n\\n\" +\n $\"Chat title:\\n{session.DisplayTitle}\\n\\n\" +\n \"Goal:\\n\" +\n \"Resume the previous conversation from that transcript. Reconstruct the latest user request, the \" +\n \"current task state, the relevant files and commands, and any unresolved next steps before you act.\\n\\n\" +\n \"Important:\\n\" +\n \"The snippets below are only a recent preview and may be truncated or incomplete. The source \" +\n \"transcript above is authoritative - read it first, do not act on the preview alone.\\n\\n\" +\n \"Preview of recent context:\\n\" +\n (string.IsNullOrWhiteSpace(preview) ? \"- (no preview available - read the transcript)\" : preview) +\n \"\\n\\nAfter reading the transcript, briefly state what you believe the active task is, then continue from there.\";\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "4d3dbd70e55c68816b9a2f76064435e24f632d2d": {"t": 1785756810.8349664, "n": 81971, "h": "0155aafa5a57b2cc", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 5; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n DeletedCollections = new List(Store.DeletedCollections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n WriteAutoBackup(); // keep the latest app backup reflecting the new project\n }\n return collection;\n }\n\n private const int MaxDeletedCollections = 100;\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n // Soft delete: the grouping moves to Recently Deleted (and an app backup is written first), so an\n // accidental delete is fully recoverable.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (!Store.Collections.TryGetValue(collectionId, out var col)) return;\n WriteAutoBackup(); // capture a restore point that still includes this collection\n Store.Collections.Remove(collectionId);\n Store.DeletedCollections.Insert(0, new DeletedCollection { Collection = col, DeletedAt = DateTime.UtcNow.ToString(\"O\") });\n while (Store.DeletedCollections.Count > MaxDeletedCollections)\n Store.DeletedCollections.RemoveAt(Store.DeletedCollections.Count - 1);\n await SaveAsync();\n }\n\n // Bring a soft-deleted collection back. If a collection with the same id now exists, its chats are\n // merged in rather than overwritten.\n public async Task RestoreDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx < 0) return;\n var col = Store.DeletedCollections[idx].Collection;\n Store.DeletedCollections.RemoveAt(idx);\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n }\n await SaveAsync();\n }\n\n // Permanently drop a collection from Recently Deleted (app backups may still hold it).\n public async Task PurgeDeletedCollectionAsync(string collectionId)\n {\n var idx = Store.DeletedCollections.FindIndex(d => d.Collection.Id == collectionId);\n if (idx >= 0) { Store.DeletedCollections.RemoveAt(idx); await SaveAsync(); }\n }\n\n public async Task EmptyRecentlyDeletedAsync()\n {\n if (Store.DeletedCollections.Count == 0) return;\n Store.DeletedCollections.Clear();\n await SaveAsync();\n }\n\n // ---- Backup / export / import (lightweight metadata only) -------------------------------------\n\n private const int MaxAutoBackups = 30;\n public string CollectionBackupsDir => Path.Combine(Path.GetDirectoryName(_storePath)!, \"collection-backups\");\n\n // Serialize the current collections to a portable JSON backup (no chat content).\n public string ExportCollectionsJson()\n {\n var backup = new CollectionsBackup\n {\n ExportedAt = DateTime.UtcNow.ToString(\"O\"),\n Collections = Store.Collections.Values.ToList()\n };\n return JsonSerializer.Serialize(backup, _jsonOptions);\n }\n\n // Merge a backup into the current collections: new ones are added, existing ones gain any missing\n // chats. Never destructive. Returns the number of collections added, or -1 if the file isn't valid.\n public async Task ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize(json, _jsonOptions); }\n catch { return -1; }\n if (backup?.Collections is null) return -1;\n\n var added = 0;\n foreach (var col in backup.Collections)\n {\n if (string.IsNullOrWhiteSpace(col.Id)) col.Id = Slug(col.Name);\n if (string.IsNullOrWhiteSpace(col.Id)) continue;\n if (Store.Collections.TryGetValue(col.Id, out var existing))\n {\n foreach (var sid in col.SessionIds)\n if (!existing.SessionIds.Contains(sid)) existing.SessionIds.Add(sid);\n }\n else\n {\n Store.Collections[col.Id] = col;\n added++;\n }\n }\n await SaveAsync();\n return added;\n }\n\n // App-side automatic backup: a timestamped snapshot of collections, keeping the most recent N.\n public void WriteAutoBackup()\n {\n try\n {\n Directory.CreateDirectory(CollectionBackupsDir);\n var name = \"collections-\" + DateTime.UtcNow.ToString(\"yyyyMMdd-HHmmss\") + \".json\";\n File.WriteAllText(Path.Combine(CollectionBackupsDir, name), ExportCollectionsJson());\n foreach (var old in Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .OrderByDescending(f => f).Skip(MaxAutoBackups))\n try { File.Delete(old); } catch { }\n }\n catch { /* backups are best-effort; never block the app */ }\n }\n\n // The app's automatic backups, newest first.\n public IReadOnlyList<(string Path, DateTime When, int Count)> ListAppBackups()\n {\n try\n {\n if (!Directory.Exists(CollectionBackupsDir)) return Array.Empty<(string, DateTime, int)>();\n return Directory.GetFiles(CollectionBackupsDir, \"collections-*.json\")\n .Select(f =>\n {\n var count = 0;\n try { count = JsonSerializer.Deserialize(File.ReadAllText(f), _jsonOptions)?.Collections.Count ?? 0; }\n catch { }\n return (f, File.GetLastWriteTime(f), count);\n })\n .OrderByDescending(x => x.Item2)\n .ToList();\n }\n catch { return Array.Empty<(string, DateTime, int)>(); }\n }\n\n public async Task RestoreAppBackupAsync(string path)\n {\n if (!File.Exists(path)) return -1;\n return await ImportCollectionsJsonAsync(File.ReadAllText(path));\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets. Priority:\n // 1. an explicit session id (cmd.id, or cmd.target when it's a concrete id);\n // 2. \"self\"/\"latest\"/no target -> the session whose SOURCE TRANSCRIPT was most recently written.\n // The agent issuing the command is, at that moment, the one actively appending to its own\n // transcript, so file mtime is the reliable \"which chat is live\" signal. We prefer chats in the\n // agent's working directory (cwd) to disambiguate a folder with many chats, and fall back to all\n // chats if cwd matches nothing (e.g. the agent didn't send one, or it's a sub/parent dir).\n // NOTE: parsed UpdatedAt is NOT used to pick — it reflects an in-transcript timestamp that can be\n // stale or wrong (a long chat can report a months-old time), which used to grab the wrong sibling.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n IEnumerable candidates = Store.Sessions.Values;\n if (!string.IsNullOrWhiteSpace(cmd.tool))\n candidates = candidates.Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase));\n\n if (!string.IsNullOrWhiteSpace(cmd.cwd))\n {\n var norm = NormalizePath(cmd.cwd!);\n var scoped = candidates.Where(s => NormalizePath(s.Workspace) == norm).ToList();\n if (scoped.Count > 0) candidates = scoped; // else fall through to the global newest-written chat\n }\n\n return candidates\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "3d0d239875eb92c00b2771d4bb911fca17845d31": {"t": 1785756812.769124, "n": 76241, "h": "1d3df80865626b38", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 5; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n // The reader keeps the MOST RECENT messages (not the oldest), so a long chat opens on its latest\n // turns. The first prompt is still captured for the title before this window is applied.\n private const int RecentMessageWindow = 600;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n }\n return collection;\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets. Priority:\n // 1. an explicit session id (cmd.id, or cmd.target when it's a concrete id);\n // 2. \"self\"/\"latest\"/no target -> the session whose SOURCE TRANSCRIPT was most recently written.\n // The agent issuing the command is, at that moment, the one actively appending to its own\n // transcript, so file mtime is the reliable \"which chat is live\" signal. We prefer chats in the\n // agent's working directory (cwd) to disambiguate a folder with many chats, and fall back to all\n // chats if cwd matches nothing (e.g. the agent didn't send one, or it's a sub/parent dir).\n // NOTE: parsed UpdatedAt is NOT used to pick — it reflects an in-transcript timestamp that can be\n // stale or wrong (a long chat can report a months-old time), which used to grab the wrong sibling.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n IEnumerable candidates = Store.Sessions.Values;\n if (!string.IsNullOrWhiteSpace(cmd.tool))\n candidates = candidates.Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase));\n\n if (!string.IsNullOrWhiteSpace(cmd.cwd))\n {\n var norm = NormalizePath(cmd.cwd!);\n var scoped = candidates.Where(s => NormalizePath(s.Workspace) == norm).ToList();\n if (scoped.Count > 0) candidates = scoped; // else fall through to the global newest-written chat\n }\n\n return candidates\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n // Title comes from the first prompt, captured from the FULL message list before we trim to the\n // recent window (so a long chat keeps its real opening title).\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n // The session's real name: a user/agent custom-title wins, then Claude's ai-title, then a\n // summary record, then the first prompt. Custom/ai titles are appended at the file TAIL (often\n // far past the message window), so scan the tail for them rather than relying on the forward parse.\n var (tailCustom, tailAi) = ClaudeTailTitle(filePath);\n var titleSource =\n !string.IsNullOrWhiteSpace(tailCustom) ? tailCustom! :\n !string.IsNullOrWhiteSpace(tailAi) ? tailAi! :\n !string.IsNullOrWhiteSpace(summary) ? summary :\n FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath);\n var title = CleanTitle(titleSource);\n var total = messages.Count;\n (messages, codeBlocks) = KeepRecentWindow(messages, codeBlocks);\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = total,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n // Trim a parsed transcript to its most recent messages so a long chat opens on its latest turns\n // (memory stays bounded). Code blocks follow the kept messages. The caller captures the title from\n // the full list BEFORE calling this, so trimming never loses the opening prompt.\n private static (ObservableCollection messages, ObservableCollection codeBlocks)\n KeepRecentWindow(ObservableCollection messages, ObservableCollection codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection(keep),\n new ObservableCollection(keep.SelectMany(m => m.CodeBlocks)));\n }\n\n // Read the last 96KB of a Claude transcript for the latest custom-title / ai-title record. Renames\n // are appended at the file TAIL (often far past the message window), so the forward parse misses\n // them; this is how the reader and list show the real session name instead of the first prompt.\n private static (string? custom, string? ai) ClaudeTailTitle(string path)\n {\n try\n {\n using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n var n = (int)Math.Min(fs.Length, 96 * 1024);\n if (n <= 0) return (null, null);\n fs.Seek(-n, SeekOrigin.End);\n var buf = new byte[n];\n var read = fs.Read(buf, 0, n);\n string? custom = null, ai = null;\n foreach (var line in Encoding.UTF8.GetString(buf, 0, read).Split('\\n'))\n {\n if (line.IndexOf(\"Title\", StringComparison.Ordinal) < 0) continue;\n try\n {\n using var d = JsonDocument.Parse(line);\n var r = d.RootElement;\n if (r.TryGetProperty(\"customTitle\", out var cu) && cu.GetString() is { Length: > 0 } cuv) custom = cuv; // latest wins\n else if (r.TryGetProperty(\"aiTitle\", out var at) && at.GetString() is { Length: > 0 } atv) ai = atv;\n }\n catch { }\n }\n return (custom, ai);\n }\n catch { return (null, null); }\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "c630be0c554f0481d9e4bc5c4cd284af3ccf9014": {"t": 1785756814.5450494, "n": 72889, "h": "3f73695ad470703a", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n }\n return collection;\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets. Priority:\n // 1. an explicit session id (cmd.id, or cmd.target when it's a concrete id);\n // 2. \"self\"/\"latest\"/no target -> the session whose SOURCE TRANSCRIPT was most recently written.\n // The agent issuing the command is, at that moment, the one actively appending to its own\n // transcript, so file mtime is the reliable \"which chat is live\" signal. We prefer chats in the\n // agent's working directory (cwd) to disambiguate a folder with many chats, and fall back to all\n // chats if cwd matches nothing (e.g. the agent didn't send one, or it's a sub/parent dir).\n // NOTE: parsed UpdatedAt is NOT used to pick — it reflects an in-transcript timestamp that can be\n // stale or wrong (a long chat can report a months-old time), which used to grab the wrong sibling.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n IEnumerable candidates = Store.Sessions.Values;\n if (!string.IsNullOrWhiteSpace(cmd.tool))\n candidates = candidates.Where(s => string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase));\n\n if (!string.IsNullOrWhiteSpace(cmd.cwd))\n {\n var norm = NormalizePath(cmd.cwd!);\n var scoped = candidates.Where(s => NormalizePath(s.Workspace) == norm).ToList();\n if (scoped.Count > 0) candidates = scoped; // else fall through to the global newest-written chat\n }\n\n return candidates\n .OrderByDescending(SourceFileWriteTimeUtc)\n .ThenByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n // The last-write time of a session's source transcript on disk (resolving a stored relative path\n // against the root). This is what makes \"self\" pick the chat that's live right now; if the file\n // is gone we fall back to the parsed UpdatedAt so ordering still degrades sanely.\n private DateTime SourceFileWriteTimeUtc(ArchiveSession s)\n {\n try\n {\n var path = string.IsNullOrEmpty(s.SourcePath) ? \"\"\n : Path.IsPathRooted(s.SourcePath) ? s.SourcePath : Path.Combine(_rootPath, s.SourcePath);\n if (path.Length > 0 && File.Exists(path)) return File.GetLastWriteTimeUtc(path);\n }\n catch { }\n return DateTime.TryParse(s.UpdatedAt, out var d) ? d.ToUniversalTime() : DateTime.MinValue;\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = messages.Count,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = messages.Count,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "afa3407bb842af986f26b5612945a07be1a4c29e": {"t": 1785756816.3303576, "n": 71130, "h": "9b8d3a7ac304c084", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Create an empty collection from the Collections control panel (no chat needed yet). Returns\n // the collection, whether it was newly created or already existed under the same slug — so an\n // agent self-filing into the same name later lands in this exact collection.\n public async Task CreateCollectionAsync(string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n await SaveAsync();\n }\n return collection;\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = messages.Count,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = messages.Count,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "9160c3faef213416b62a02554190f853c2247bc1": {"t": 1785756818.2572787, "n": 70401, "h": "e7cfabb13cfcd17e", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n if (mode is not (\"path\" or \"paths\")) EnsureContent(session); // restore/code/resume need content (lazy)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n // Read the (slow) sqlite/jsonl titles OFF the UI thread; apply on this (UI) thread so the\n // INotifyPropertyChanged raised by ApplyThreadTitles never fires from a worker. The final\n // RefreshSessions below repaints the list, so we don't refresh here.\n var titles = await Task.Run(LoadThreadTitles);\n ApplyThreadTitles(titles);\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n // Off-thread variant: the Source inspector reads + JSON-parses up to `limit` lines, which must\n // not happen on the UI thread for a large rollout.\n public Task> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = messages.Count,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = messages.Count,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "eae5e0cf9f6b3676162ea2101a70f7529502bc97": {"t": 1785756819.9220417, "n": 69825, "h": "5fd4d898d79f9f0e", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n private const int SearchTextCap = 6_000; // capped, in-memory searchable text per session (full content lazy-loads)\n\n private static string CapText(string s) => string.IsNullOrEmpty(s) || s.Length <= SearchTextCap ? s : s[..SearchTextCap];\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n var data = JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n // A store written before the metadata-only change still has full (uncapped) Text; cap it in\n // memory so even the first load is light. Next save persists the capped form.\n foreach (var s in data.Sessions.Values)\n if (s.Text.Length > SearchTextCap) s.Text = s.Text[..SearchTextCap];\n return data;\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n // Lazy-load a chat's full content (messages + code blocks) from its source file on demand, OFF the\n // UI thread. The store holds only metadata, so this runs when a chat is opened or its content is\n // needed (co-pilot, copy-code). Cheap no-op once loaded.\n public async Task EnsureContentAsync(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n // SourcePath may be relative (the bundled demo store) — resolve against the project root.\n var path = string.IsNullOrEmpty(session.SourcePath) ? \"\" :\n Path.IsPathRooted(session.SourcePath) ? session.SourcePath : Path.Combine(_rootPath, session.SourcePath);\n if (path.Length == 0 || !File.Exists(path))\n {\n session.ContentLoaded = true;\n return;\n }\n var parsed = await Task.Run(async () => await ParseSessionAsync(path, session.Tool));\n if (parsed is not null)\n {\n session.Messages = parsed.Messages;\n session.CodeBlocks = parsed.CodeBlocks;\n session.MessageCount = parsed.Messages.Count;\n }\n session.ContentLoaded = true;\n }\n\n // Synchronous lazy-load for callers that can't await (the co-pilot's sync tool delegates, the web read\n // handler). Runs the parse on the thread pool (no captured context) so it can't deadlock the UI.\n public void EnsureContent(ArchiveSession session)\n {\n if (session.ContentLoaded) return;\n Task.Run(() => EnsureContentAsync(session)).GetAwaiter().GetResult();\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n EnsureContent(session); // restore/code/resume need the messages + code blocks (lazy-loaded)\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n EnrichTitlesFromLocalState();\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = messages.Count,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n MessageCount = messages.Count,\n ContentLoaded = true,\n Text = CapText(string.Join(\"\\n\\n\", messages.Select(m => m.Text))),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n // Content match against the capped, in-memory search text (the full transcript lazy-loads on open,\n // so we don't hold every message of every chat in memory just to search).\n var contentScore = FuzzyScore(session.Text, terms);\n if (contentScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"chat content\",\n Snippet = MakeSnippet(session.Text, terms),\n MatchedTerms = MatchedTerms(session.Text, terms),\n Score = contentScore\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState() => ApplyThreadTitles(LoadThreadTitles());\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "d7e82b9300c30e695eb3b6297553a91812c3ad0a": {"t": 1785756821.6498513, "n": 67271, "h": "27bcd865a1e7e71b", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n return JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // The store can be tens of MB, and SaveAsync runs on the UI thread from many actions (pin, rename,\n // sync). Snapshot the top-level dictionaries on the caller's thread (cheap — references only, and the\n // merge REPLACES session objects rather than mutating them, so the snapshot stays stable), then\n // serialize + write entirely OFF the UI thread so a save never freezes the app.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var snapshot = new AppStoreData\n {\n Settings = Store.Settings,\n Sessions = new Dictionary(Store.Sessions),\n Collections = new Dictionary(Store.Collections),\n FileStamps = new Dictionary(Store.FileStamps),\n };\n await _saveGate.WaitAsync();\n try\n {\n await Task.Run(async () =>\n {\n var json = JsonSerializer.Serialize(snapshot, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n });\n }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n EnrichTitlesFromLocalState();\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState() => ApplyThreadTitles(LoadThreadTitles());\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "24d2afb65f2228c43d6def46a9d8692e2bb2abb5": {"t": 1785756823.588844, "n": 66595, "h": "ec8457204063824d", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n // The store can be tens of MB. Read + deserialize OFF the calling (UI) thread, straight from\n // the file stream (no giant intermediate string), so launch never blocks the UI.\n Store = await Task.Run(() =>\n {\n using var fs = File.OpenRead(loadPath);\n return JsonSerializer.Deserialize(fs, _jsonOptions) ?? new AppStoreData();\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // Serialize a synchronous snapshot, then gate the file write so overlapping saves\n // (a background sync finishing while the user pins/files a chat) can't clobber each other.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var json = JsonSerializer.Serialize(Store, _jsonOptions);\n await _saveGate.WaitAsync();\n try { await File.WriteAllTextAsync(_storePath, json); }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n EnrichTitlesFromLocalState();\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState() => ApplyThreadTitles(LoadThreadTitles());\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "f29db3ee7c67010550d2cfcda885fd5bcd98c3a3": {"t": 1785756825.4988017, "n": 66307, "h": "71eeccd808bb0ae3", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n var json = await File.ReadAllTextAsync(loadPath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // Serialize a synchronous snapshot, then gate the file write so overlapping saves\n // (a background sync finishing while the user pins/files a chat) can't clobber each other.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var json = JsonSerializer.Serialize(Store, _jsonOptions);\n await _saveGate.WaitAsync();\n try { await File.WriteAllTextAsync(_storePath, json); }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n EnrichTitlesFromLocalState();\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n\n var isClaude = string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase);\n var exe = !string.IsNullOrWhiteSpace(exeOverride) ? exeOverride!\n : isClaude ? ResolveClaudeExe() : ResolveCodexExe();\n\n // The terminal opens in the chat's workspace, and cmd resolves a bare command against the\n // current directory before PATH — so a workspace that contains a planted codex.exe/claude.bat\n // could be run. Require a trusted ABSOLUTE existing exe (overrides are caller-trusted, e.g. tests).\n if (string.IsNullOrWhiteSpace(exeOverride) && (!Path.IsPathRooted(exe) || !File.Exists(exe)))\n return new ResumeLaunch(\"\", \"\", cwd, $\"Refused: the {(isClaude ? \"claude\" : \"codex\")} CLI was not found at a trusted path.\");\n\n var args = isClaude ? $\"--resume {id}\" : $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState() => ApplyThreadTitles(LoadThreadTitles());\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "08fedd513748c2ec7a8b540886b6d15b0b3d5426": {"t": 1785756827.5058215, "n": 65927, "h": "6bb554c9ea24cc71", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n var json = await File.ReadAllTextAsync(loadPath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // Serialize a synchronous snapshot, then gate the file write so overlapping saves\n // (a background sync finishing while the user pins/files a chat) can't clobber each other.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var json = JsonSerializer.Serialize(Store, _jsonOptions);\n await _saveGate.WaitAsync();\n try { await File.WriteAllTextAsync(_storePath, json); }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Narrow by-id operations for the co-pilot tools. App metadata only — these never write the\n // canonical agent store and never touch source .jsonl files.\n public ArchiveSession? GetSession(string sessionId) =>\n Store.Sessions.TryGetValue(sessionId, out var session) ? session : null;\n\n public async Task SetFavoriteAsync(string sessionId, bool favorite)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n session.Pinned = favorite;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return true;\n }\n\n public async Task RenameLocalAsync(string sessionId, string title)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await RenameSessionAsync(session, title); // CustomTitle only; no canonical write-back\n return true;\n }\n\n public async Task AddToProjectByIdAsync(string sessionId, string project)\n {\n if (!Store.Sessions.TryGetValue(sessionId, out var session)) return false;\n await AddToCollectionAsync(session, project);\n return true;\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n EnrichTitlesFromLocalState();\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n if (string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase))\n {\n var claude = string.IsNullOrWhiteSpace(exeOverride) ? ResolveClaudeExe() : exeOverride!;\n var claudeArgs = $\"--resume {id}\";\n return new ResumeLaunch(claude, claudeArgs, cwd, $\"\\\"{claude}\\\" {claudeArgs}\");\n }\n var exe = string.IsNullOrWhiteSpace(exeOverride) ? ResolveCodexExe() : exeOverride!;\n var args = $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState() => ApplyThreadTitles(LoadThreadTitles());\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "f872273b88229ab943352fa808e43c5ff87d23a9": {"t": 1785756829.173127, "n": 64774, "h": "67ce5d5fe8b475f9", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n var json = await File.ReadAllTextAsync(loadPath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // Serialize a synchronous snapshot, then gate the file write so overlapping saves\n // (a background sync finishing while the user pins/files a chat) can't clobber each other.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var json = JsonSerializer.Serialize(Store, _jsonOptions);\n await _saveGate.WaitAsync();\n try { await File.WriteAllTextAsync(_storePath, json); }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Delete a project/collection — only the grouping; the chats themselves stay in the archive.\n public async Task RemoveCollectionAsync(string collectionId)\n {\n if (Store.Collections.Remove(collectionId)) await SaveAsync();\n }\n\n // Remove a single chat from a project without deleting the project or the chat.\n public async Task RemoveFromCollectionAsync(string collectionId, string sessionId)\n {\n if (Store.Collections.TryGetValue(collectionId, out var collection) && collection.SessionIds.Remove(sessionId))\n await SaveAsync();\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n EnrichTitlesFromLocalState();\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n if (string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase))\n {\n var claude = string.IsNullOrWhiteSpace(exeOverride) ? ResolveClaudeExe() : exeOverride!;\n var claudeArgs = $\"--resume {id}\";\n return new ResumeLaunch(claude, claudeArgs, cwd, $\"\\\"{claude}\\\" {claudeArgs}\");\n }\n var exe = string.IsNullOrWhiteSpace(exeOverride) ? ResolveCodexExe() : exeOverride!;\n var args = $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState() => ApplyThreadTitles(LoadThreadTitles());\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "6e18e56ddecf7e1579ca8952da6a59aa84a9615c": {"t": 1785756830.9615908, "n": 64191, "h": "6f6f9805dea47b53", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int CurrentIndexVersion = 4; // bump on any parser change to force a full re-parse\n private const int MaxIndexedFiles = 4000;\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n\n private readonly string _rootPath;\n private readonly string _storePath;\n private readonly string _bundledStorePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n private readonly SemaphoreSlim _saveGate = new(1, 1);\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, bool useBundledStore = false)\n {\n _rootPath = FindProjectRoot();\n _bundledStorePath = Path.Combine(_rootPath, \"data\", \"app-store.json\");\n _storePath = useBundledStore\n ? _bundledStorePath\n : storePath ?? Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"CodexLocalRetrieval\",\n \"app-store.json\");\n }\n\n public async Task LoadAsync()\n {\n var loadPath = File.Exists(_storePath) ? _storePath : _bundledStorePath;\n if (File.Exists(loadPath))\n {\n var json = await File.ReadAllTextAsync(loadPath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n // Read the (potentially slow) sqlite/jsonl off-thread, but APPLY the changes on the caller\n // (UI) thread — mutating bound sessions raises INotifyPropertyChanged, which must not fire\n // from a worker.\n var titles = await Task.Run(LoadThreadTitles);\n var changed = ApplyThreadTitles(titles);\n if (changed) RefreshSessions(Store.Sessions.Values);\n return changed;\n }\n\n public async Task SaveAsync()\n {\n // Serialize a synchronous snapshot, then gate the file write so overlapping saves\n // (a background sync finishing while the user pins/files a chat) can't clobber each other.\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var json = JsonSerializer.Serialize(Store, _jsonOptions);\n await _saveGate.WaitAsync();\n try { await File.WriteAllTextAsync(_storePath, json); }\n finally { _saveGate.Release(); }\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n // Apply one command from an outside agent (see AGENTS.md). Lets a user point any Claude/Codex\n // chat at the app and say \"set yourself up / favorite yourself / file yourself into project X\".\n public async Task ApplyAgentCommandAsync(AgentCommand cmd)\n {\n switch ((cmd.op ?? \"\").Trim().ToLowerInvariant())\n {\n case \"init\":\n EnsureDefaultSources();\n await SaveAsync();\n return new AgentCommandResult(true, \"Initialized default sources (Codex + Claude).\");\n\n case \"addsource\":\n if (string.IsNullOrWhiteSpace(cmd.root) || string.IsNullOrWhiteSpace(cmd.tool))\n return new AgentCommandResult(false, \"addSource needs both 'tool' and 'root'.\");\n EnsureDefaultSources();\n if (!Store.Settings.Sources.Any(s => string.Equals(s.Root, cmd.root, StringComparison.OrdinalIgnoreCase)\n && string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase)))\n Store.Settings.Sources.Add(new SessionSource { Tool = cmd.tool!.ToLowerInvariant(), Root = cmd.root! });\n await SaveAsync();\n return new AgentCommandResult(true, $\"Added {cmd.tool} source: {cmd.root}\");\n\n case \"favorite\":\n case \"pin\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to favorite.\");\n s.Pinned = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Favorited \\\"{s.DisplayTitle}\\\".\");\n }\n\n case \"addtoproject\":\n case \"addtocollection\":\n {\n if (string.IsNullOrWhiteSpace(cmd.project)) return new AgentCommandResult(false, \"addToProject needs 'project'.\");\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to add to a project.\");\n await AddToCollectionAsync(s, cmd.project!);\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Added \\\"{s.DisplayTitle}\\\" to project \\\"{cmd.project}\\\".\");\n }\n\n case \"rename\":\n {\n var s = ResolveTargetSession(cmd);\n if (s is null) return new AgentCommandResult(false, \"No matching session to rename.\");\n if (!string.IsNullOrWhiteSpace(cmd.localName)) s.CustomTitle = CleanTitle(cmd.localName!);\n var canonical = await TryWriteCanonicalNameAsync(s, cmd.canonicalName);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n return new AgentCommandResult(true, $\"Renamed to \\\"{s.DisplayTitle}\\\".{(canonical is null ? \"\" : \" \" + canonical)}\");\n }\n\n default:\n return new AgentCommandResult(false, $\"Unknown op: '{cmd.op}'.\");\n }\n }\n\n private void EnsureDefaultSources()\n {\n if (Store.Settings.Sources.Count == 0) Store.Settings.Sources.AddRange(DefaultSources());\n }\n\n // Resolve which session a command targets: an explicit id, or \"self\"/\"latest\" = the newest\n // session in the agent's workspace (cwd), optionally filtered by tool.\n public ArchiveSession? ResolveTargetSession(AgentCommand cmd)\n {\n var explicitId = cmd.id;\n if (string.IsNullOrWhiteSpace(explicitId) && !string.IsNullOrWhiteSpace(cmd.target)\n && cmd.target != \"self\" && cmd.target != \"latest\")\n explicitId = cmd.target;\n if (!string.IsNullOrWhiteSpace(explicitId) && Store.Sessions.TryGetValue(explicitId!, out var byId))\n return byId;\n\n if (string.IsNullOrWhiteSpace(cmd.cwd)) return null;\n var norm = NormalizePath(cmd.cwd!);\n return Store.Sessions.Values\n .Where(s => NormalizePath(s.Workspace) == norm)\n .Where(s => string.IsNullOrWhiteSpace(cmd.tool) || string.Equals(s.Tool, cmd.tool, StringComparison.OrdinalIgnoreCase))\n .OrderByDescending(s => s.UpdatedAt)\n .FirstOrDefault();\n }\n\n private static string NormalizePath(string p)\n {\n if (string.IsNullOrWhiteSpace(p)) return \"\";\n try { return Path.GetFullPath(p).Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n catch { return p.Replace('/', '\\\\').TrimEnd('\\\\').ToLowerInvariant(); }\n }\n\n // L11: write the canonical name back to the agent's own store so the rename shows up in\n // Codex/Claude's native resume picker too — not just inside this app.\n public async Task TryWriteCanonicalNameAsync(ArchiveSession session, string? canonicalName)\n {\n if (string.IsNullOrWhiteSpace(canonicalName)) return null;\n if (string.Equals(session.Tool, \"codex\", StringComparison.OrdinalIgnoreCase))\n return await Task.Run(() => WriteCodexThreadTitle(session.Id, canonicalName!));\n return \"Claude has no external rename API yet, so the canonical name stays app-only.\";\n }\n\n private static string WriteCodexThreadTitle(string id, string title)\n {\n try\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return \"Codex title DB not found; local name updated.\";\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadWrite };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using (var busy = connection.CreateCommand()) { busy.CommandText = \"PRAGMA busy_timeout=2000;\"; busy.ExecuteNonQuery(); }\n using var command = connection.CreateCommand();\n command.CommandText = \"update threads set title = $t where id = $id\";\n command.Parameters.AddWithValue(\"$t\", title);\n command.Parameters.AddWithValue(\"$id\", id);\n return command.ExecuteNonQuery() > 0 ? \"Canonical name written to Codex.\" : \"No matching Codex thread; local name updated.\";\n }\n catch (Exception ex)\n {\n return \"Codex canonical rename failed (\" + ex.Message + \"); local name updated.\";\n }\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n // Index one folder as Codex rollouts (kept for callers/tests that target a single codex root).\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null, bool refreshList = true)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n var (parsed, stamps) = await ParseSourceAsync(new SessionSource { Tool = \"codex\", Root = rootPath },\n new Dictionary(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List()) { Stamps = stamps }, refreshList);\n }\n\n // Default on-disk stores. Codex rollouts and Claude transcripts both land under the home dir.\n public static string DefaultCodexSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"sessions\");\n public static string DefaultClaudeSessionsRoot =>\n Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".claude\", \"projects\");\n\n public static List DefaultSources() => new()\n {\n new SessionSource { Tool = \"codex\", Root = DefaultCodexSessionsRoot },\n new SessionSource { Tool = \"claude\", Root = DefaultClaudeSessionsRoot },\n };\n\n // The roots to scan: whatever the user/agent configured, else the codex + claude defaults.\n public IReadOnlyList EffectiveSources() =>\n Store.Settings.Sources.Count > 0 ? Store.Settings.Sources : DefaultSources();\n\n // Resurface everything in one call (tests + simple callers). UI callers split this across\n // threads: ScanDiskAsync on a worker (no shared state) then MergeScanAsync on the UI thread.\n public async Task SyncFromDiskAsync(IProgress? progress = null, bool refreshList = true)\n {\n var scan = await ScanDiskAsync(progress);\n return await MergeScanAsync(scan, refreshList);\n }\n\n // OFF-THREAD SAFE: reads files only and returns parsed/changed sessions + current file stamps.\n // Iterates every configured source (codex + claude), skips files whose stamp is unchanged\n // (incremental), and routes parsing by the source's tool. Touches no shared mutable state.\n public async Task ScanDiskAsync(IProgress? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List() : LoadBundledHistory(progress);\n // Honor the incremental cache only when the parser version matches; otherwise re-parse all.\n var fullRescan = Store.Settings.IndexVersion != CurrentIndexVersion;\n var known = fullRescan\n ? new Dictionary(StringComparer.OrdinalIgnoreCase)\n : new Dictionary(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n var anyRoot = false;\n foreach (var src in EffectiveSources())\n {\n if (!src.Enabled || string.IsNullOrWhiteSpace(src.Root) || !Directory.Exists(src.Root)) continue;\n anyRoot = true;\n var (parsed, srcStamps) = await ParseSourceAsync(src, known, progress);\n disk.AddRange(parsed);\n foreach (var kv in srcStamps) stamps[kv.Key] = kv.Value;\n }\n if (!anyRoot) progress?.Report(\"No session folders found.\");\n return new DiskScan(disk, bundled) { Stamps = stamps, FullRescan = fullRescan };\n }\n\n // Enumerate one source, skip unchanged files (incremental), parse the rest by tool.\n private async Task<(List Parsed, Dictionary Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary known, IProgress? progress)\n {\n var files = Directory.EnumerateFiles(src.Root, \"*.jsonl\", SearchOption.AllDirectories)\n .Select(path => new FileInfo(path))\n .OrderByDescending(file => file.LastWriteTimeUtc)\n .Take(MaxIndexedFiles)\n .ToList();\n var parsed = new List();\n var stamps = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in files)\n {\n var stamp = file.LastWriteTimeUtc.Ticks + \":\" + file.Length;\n\n // Claude workflow/subagent journals collide on the filename \"journal\" and aren't chats.\n // Stamp them (so they're \"scanned\" -> any old orphan entry prunes + we skip them next\n // time) but don't parse them into a session.\n if (src.Tool == \"claude\" && (file.Name == \"journal.jsonl\"\n || file.FullName.Contains(\"\\\\subagents\\\\\", StringComparison.OrdinalIgnoreCase)))\n {\n stamps[file.FullName] = stamp;\n continue;\n }\n\n if (known.TryGetValue(file.FullName, out var old) && old == stamp)\n {\n stamps[file.FullName] = stamp; // unchanged: carry the stamp forward, skip the parse\n continue;\n }\n try\n {\n var session = await ParseSessionAsync(file.FullName, src.Tool);\n if (session is not null) parsed.Add(session);\n stamps[file.FullName] = stamp; // stamp only after a successful parse (or intentional sidechain skip)\n }\n catch (Exception ex)\n {\n // Do NOT stamp a failed parse, so a transient/locked read is retried next scan.\n progress?.Report($\"Skipped {file.Name}: {ex.Message}\");\n }\n }\n progress?.Report($\"{src.Tool}: {parsed.Count} new/changed of {files.Count}\");\n return (parsed, stamps);\n }\n\n // Route by tool; auto-detect when a source's tool is unknown.\n private async Task ParseSessionAsync(string path, string tool)\n {\n var resolved = string.IsNullOrWhiteSpace(tool) || tool == \"auto\" ? DetectTool(path) : tool;\n return resolved == \"claude\" ? await ParseClaudeSessionAsync(path) : await ParseJsonlAsync(path);\n }\n\n // Peek the first useful line: claude transcripts carry a sessionId; codex rollouts carry a payload.\n private static string DetectTool(string path)\n {\n try\n {\n foreach (var line in File.ReadLines(path))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Contains(\"\\\"sessionId\\\"\")) return \"claude\";\n if (line.Contains(\"\\\"payload\\\"\") || line.Contains(\"session_meta\")) return \"codex\";\n break;\n }\n }\n catch { }\n return \"codex\";\n }\n\n // UI-THREAD: merge scanned sessions into the store. Disk content always refreshes a session,\n // but app-owned organization (custom title, pin, archive, review, star, user tags) is preserved\n // so a re-sync never wipes how you've filed your chats. Bundled history only fills genuine gaps.\n public async Task MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet(StringComparer.OrdinalIgnoreCase);\n foreach (var incoming in scan.Disk)\n {\n if (Store.Sessions.TryGetValue(incoming.Id, out var existing)) PreserveAppFields(existing, incoming);\n Store.Sessions[incoming.Id] = incoming;\n imported.Add(incoming.Id);\n }\n var recovered = 0;\n foreach (var bundled in scan.Bundled)\n {\n if (string.IsNullOrWhiteSpace(bundled.Id) || Store.Sessions.ContainsKey(bundled.Id)) continue;\n Store.Sessions[bundled.Id] = bundled;\n imported.Add(bundled.Id);\n recovered++;\n }\n if (imported.Count > 0)\n {\n RemoveBundledSampleSessions(imported);\n EnrichTitlesFromLocalState();\n }\n // On a parser-version migration every file was re-parsed: prune sessions whose file WAS\n // scanned but no longer yields that id (old id scheme, or the file is now a skipped sidechain).\n if (scan.FullRescan)\n {\n var scannedPaths = new HashSet(scan.Stamps.Keys, StringComparer.OrdinalIgnoreCase);\n var orphans = Store.Sessions.Values\n .Where(s => scannedPaths.Contains(s.SourcePath) && !imported.Contains(s.Id))\n .Select(s => s.Id)\n .ToList();\n foreach (var id in orphans) Store.Sessions.Remove(id);\n }\n foreach (var kv in scan.Stamps) Store.FileStamps[kv.Key] = kv.Value;\n Store.Settings.BundledHistoryAbsorbed = true;\n Store.Settings.IndexVersion = CurrentIndexVersion;\n await SaveAsync();\n if (refreshList) RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n return scan.Disk.Count + recovered;\n }\n\n // Refresh a session's content from disk while keeping the user's organization intact.\n private static void PreserveAppFields(ArchiveSession existing, ArchiveSession incoming)\n {\n incoming.CustomTitle = existing.CustomTitle;\n incoming.Pinned = existing.Pinned;\n incoming.Archived = existing.Archived;\n incoming.Reviewed = existing.Reviewed;\n incoming.Starred = existing.Starred;\n foreach (var tag in existing.Tags)\n {\n if (!incoming.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) incoming.Tags.Add(tag);\n }\n }\n\n private List LoadBundledHistory(IProgress? progress)\n {\n var list = new List();\n try\n {\n if (string.Equals(_storePath, _bundledStorePath, StringComparison.OrdinalIgnoreCase)) return list;\n if (!File.Exists(_bundledStorePath)) return list;\n var bundled = JsonSerializer.Deserialize(File.ReadAllText(_bundledStorePath), _jsonOptions);\n if (bundled is null) return list;\n foreach (var pair in bundled.Sessions)\n {\n if (IsSampleSource(pair.Value.SourcePath)) continue;\n list.Add(pair.Value);\n }\n if (list.Count > 0) progress?.Report($\"Recovering {list.Count} archived chats from local history...\");\n }\n catch\n {\n // History recovery is best-effort; a missing or partial snapshot must not block a sync.\n }\n return list;\n }\n\n private static bool IsSampleSource(string sourcePath) =>\n sourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || sourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase);\n\n // Everything the UI needs to actually resume a chat in a terminal, routed by which agent owns\n // it: Claude uses `claude --resume `; Codex uses `codex resume --include-non-interactive\n // ` (options before the positional id; include-non-interactive so exec rollouts qualify).\n // Both start in the chat's original workspace cwd.\n // A session id is only ever a UUID or a rollout/file token. Anything else (e.g. a crafted\n // filename `x & calc` from a malicious source root) is refused so it can't be injected into the\n // terminal command line.\n public static bool IsResumableId(string id) =>\n !string.IsNullOrWhiteSpace(id) && id.Length <= 200 && Regex.IsMatch(id, \"^[A-Za-z0-9._-]+$\");\n\n public ResumeLaunch BuildResumeLaunch(ArchiveSession session, string? exeOverride = null)\n {\n var id = string.IsNullOrWhiteSpace(session.Id) ? Path.GetFileNameWithoutExtension(session.SourcePath) : session.Id;\n var cwd = ResolveWorkingDirectory(session);\n if (!IsResumableId(id))\n return new ResumeLaunch(\"\", \"\", cwd, \"Refused: session id is not a safe token.\");\n if (string.Equals(session.Tool, \"claude\", StringComparison.OrdinalIgnoreCase))\n {\n var claude = string.IsNullOrWhiteSpace(exeOverride) ? ResolveClaudeExe() : exeOverride!;\n var claudeArgs = $\"--resume {id}\";\n return new ResumeLaunch(claude, claudeArgs, cwd, $\"\\\"{claude}\\\" {claudeArgs}\");\n }\n var exe = string.IsNullOrWhiteSpace(exeOverride) ? ResolveCodexExe() : exeOverride!;\n var args = $\"resume --include-non-interactive {id}\";\n return new ResumeLaunch(exe, args, cwd, $\"\\\"{exe}\\\" {args}\");\n }\n\n public static string ResolveClaudeExe()\n {\n var local = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".local\", \"bin\", \"claude.exe\");\n return File.Exists(local) ? local : \"claude\";\n }\n\n private static string ResolveWorkingDirectory(ArchiveSession session)\n {\n if (!string.IsNullOrWhiteSpace(session.Workspace) && Directory.Exists(session.Workspace)) return session.Workspace;\n var sourceDir = Path.GetDirectoryName(session.SourcePath);\n if (!string.IsNullOrWhiteSpace(sourceDir) && Directory.Exists(sourceDir)) return sourceDir;\n return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n }\n\n public static string ResolveCodexExe()\n {\n var local = Path.Combine(\n Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),\n \"OpenAI\", \"Codex\", \"bin\", \"codex.exe\");\n return File.Exists(local) ? local : \"codex\";\n }\n\n // The raw rollout timeline: one entry per recorded event (messages, tool/function calls,\n // command runs, reasoning) so the Source inspector shows what actually happened, not a path.\n public IReadOnlyList ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List();\n if (string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath)) return events;\n try\n {\n using var stream = new FileStream(session.SourcePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n string? line;\n while (events.Count < limit && (line = reader.ReadLine()) is not null)\n {\n if (string.IsNullOrWhiteSpace(line) || line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (!root.TryGetProperty(\"payload\", out var payload)) continue;\n // Prefer payload.type; fall back to the line's root type (e.g. session_meta).\n var kind =\n payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() is { Length: > 0 } pt ? pt :\n root.TryGetProperty(\"type\", out var rootType) ? rootType.GetString() ?? \"event\" : \"event\";\n if (kind == \"message\" && payload.TryGetProperty(\"role\", out var roleProp))\n kind = \"message:\" + (roleProp.GetString() ?? \"\");\n events.Add(new RawEvent(kind, timestamp, EventPreview(kind, payload)));\n }\n catch\n {\n // One malformed event must not abort the rest of the timeline.\n }\n }\n }\n }\n catch\n {\n // Source files are read-only previews; a locked or partial file just yields fewer events.\n }\n return events;\n }\n\n // Best-effort human preview across the many rollout payload shapes (messages, tool calls,\n // command runs, patch/mcp results). Never throws and never returns raw JSON for the common cases.\n private static string EventPreview(string kind, JsonElement payload)\n {\n string raw =\n Field(payload, \"message\") is { Length: > 0 } msg ? msg :\n payload.TryGetProperty(\"content\", out _) ? ExtractContent(payload) :\n CommandText(payload) is { Length: > 0 } cmd ? cmd :\n Field(payload, \"output\") is { Length: > 0 } outp ? outp :\n Field(payload, \"stdout\") is { Length: > 0 } so ? so :\n Field(payload, \"stderr\") is { Length: > 0 } se ? se :\n Field(payload, \"input\") is { Length: > 0 } inp ? inp :\n Field(payload, \"arguments\") is { Length: > 0 } argp ? argp :\n Field(payload, \"result\") is { Length: > 0 } res ? res :\n Field(payload, \"last_agent_message\") is { Length: > 0 } lam ? lam :\n Field(payload, \"name\") is { Length: > 0 } nm ? nm :\n Field(payload, \"text\") is { Length: > 0 } tx ? tx :\n \"\";\n raw = Regex.Replace(raw.Trim(), \"\\\\s+\", \" \");\n return raw.Length > 200 ? raw[..200] + \"...\" : raw;\n }\n\n private static string Field(JsonElement payload, string prop)\n {\n if (!payload.TryGetProperty(prop, out var v)) return \"\";\n return v.ValueKind switch\n {\n JsonValueKind.String => v.GetString() ?? \"\",\n JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => v.ToString(),\n JsonValueKind.Object or JsonValueKind.Array => v.ToString(),\n _ => \"\"\n };\n }\n\n private static string CommandText(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"command\", out var c)) return \"\";\n return c.ValueKind == JsonValueKind.Array\n ? string.Join(\" \", c.EnumerateArray().Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()))\n : c.ToString();\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n // A partial/malformed line — common at the tail of an in-progress rollout, i.e. the\n // very session you most want to resume — must not discard the whole file. Skip the line.\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Tool = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude Code transcript: one JSON object per line with sessionId/cwd/timestamp and a\n // message{role,content[]}. content is an array of {type:\"text\",text} blocks (plus tool_use/\n // tool_result we skip for the reader). Mirrors ParseJsonlAsync but for Claude's shape.\n private async Task ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n // The filename IS Claude's resumable session id; the in-line sessionId is shared by a\n // session's sidechain (subagent) files, so we never key off it.\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var summary = \"\";\n var isSidechain = false;\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n var updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n JsonDocument doc;\n try { doc = JsonDocument.Parse(line); } catch { continue; }\n using (doc)\n {\n try\n {\n var root = doc.RootElement;\n if (root.TryGetProperty(\"isSidechain\", out var scProp) && scProp.ValueKind == JsonValueKind.True) isSidechain = true;\n if (root.TryGetProperty(\"cwd\", out var cwdProp) && cwdProp.GetString() is { Length: > 0 } cw) cwd = cw;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created) && !string.IsNullOrWhiteSpace(timestamp)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n var type = root.TryGetProperty(\"type\", out var typeProp) ? typeProp.GetString() : null;\n if (type == \"summary\" && root.TryGetProperty(\"summary\", out var sumProp))\n {\n summary = sumProp.GetString() ?? summary;\n continue;\n }\n if (type != \"user\" && type != \"assistant\") continue;\n if (!root.TryGetProperty(\"message\", out var message)) continue;\n var role = message.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? type : type;\n if (!IsIndexedRole(role)) continue;\n var text = ExtractClaudeContent(message);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n catch\n {\n // One bad line never discards the session.\n }\n }\n }\n\n // Sidechain (subagent) transcripts aren't independently resumable conversations — skip them\n // so the list shows one entry per real chat instead of hundreds of subagent fragments.\n if (isSidechain) return null;\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n var title = !string.IsNullOrWhiteSpace(summary)\n ? CleanTitle(summary)\n : CleanTitle(FirstMeaningfulUserText(messages) ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"claude\",\n Tool = \"claude\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n // Claude message.content is usually an array of typed blocks; keep the readable text ones.\n private static string ExtractClaudeContent(JsonElement message)\n {\n if (!message.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return \"\";\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n var itemType = item.TryGetProperty(\"type\", out var it) ? it.GetString() : null;\n if (itemType == \"text\" && item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (itemType == \"tool_result\" && item.TryGetProperty(\"content\", out var tr))\n {\n if (tr.ValueKind == JsonValueKind.String) builder.AppendLine(tr.GetString());\n }\n }\n return builder.ToString();\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet importedIds)\n {\n var sampleIds = Store.Sessions.Values\n .Where(session => !importedIds.Contains(session.Id))\n .Where(session =>\n session.SourcePath.Contains(\"sample-sessions\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data\\\\fixtures\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.Contains(\"data/fixtures\", StringComparison.OrdinalIgnoreCase))\n .Select(session => session.Id)\n .ToList();\n\n foreach (var id in sampleIds)\n {\n Store.Sessions.Remove(id);\n }\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n // Array items can be primitives, not just objects — TryGetProperty would throw on those.\n if (item.ValueKind == JsonValueKind.String) { builder.AppendLine(item.GetString()); continue; }\n if (item.ValueKind != JsonValueKind.Object) continue;\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static readonly Regex NoiseBlocks = new(\n \"<(environment_context|goal_context|ide_selection|ide_opened_file|ide_diagnostics|system-reminder|user_instructions|context|command-message|command-name|command-args|local-command-stdout|local-command-stderr)>[\\\\s\\\\S]*?\",\n RegexOptions.IgnoreCase | RegexOptions.Compiled);\n\n // First user message that reads like a real prompt (machine-context noise stripped) — used for\n // the chat title so the list shows what the chat is about, not an IDE/context preamble.\n private static string? FirstMeaningfulUserText(IEnumerable messages)\n {\n foreach (var message in messages.Where(m => m.Role == \"user\"))\n {\n var clean = ForReading(message.Text);\n if (clean.Length >= 8 && !clean.StartsWith(\"<\")) return clean;\n }\n return null;\n }\n\n // Strip the machine-context noise (IDE/environment/goal/system-reminder blocks) and fenced\n // code from a message so the readable human conversation is what surfaces in the reader.\n public static string ForReading(string text)\n {\n if (string.IsNullOrEmpty(text)) return \"\";\n var value = Regex.Replace(text, \"```[\\\\s\\\\S]*?```\", \"\").Trim();\n value = NoiseBlocks.Replace(value, \"\");\n value = Regex.Replace(value, \"(?im)^#\\\\s*Context from my IDE setup:.*$\", \"\");\n value = Regex.Replace(value, \"\\\\n{3,}\", \"\\n\\n\").Trim();\n return value;\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState() => ApplyThreadTitles(LoadThreadTitles());\n\n private bool ApplyThreadTitles(Dictionary titles)\n {\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "af94a85272b8aaca83fe080e02c7709ec321385a": {"t": 1785756832.709495, "n": 36446, "h": "4bf8d32da26e1075", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private const int MaxMessagesPerSession = 220;\n private const int MaxLinesPerSession = 18_000;\n private const int MaxLineChars = 512_000;\n\n private readonly string _contentRootPath;\n private readonly string _seedStorePath;\n private readonly string _storePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, string? contentRootPath = null)\n {\n _contentRootPath = contentRootPath ?? FindProjectRoot();\n _seedStorePath = Path.Combine(_contentRootPath, \"data\", \"app-store.json\");\n _storePath = storePath ?? DefaultStorePath();\n }\n\n public async Task LoadAsync()\n {\n if (File.Exists(_storePath))\n {\n var json = await File.ReadAllTextAsync(_storePath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n else if (File.Exists(_seedStorePath))\n {\n var json = await File.ReadAllTextAsync(_seedStorePath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public string ResolveDefaultChatRoot()\n {\n if (!string.IsNullOrWhiteSpace(Store.Settings.ChatRootPath) && Directory.Exists(Store.Settings.ChatRootPath))\n {\n return Store.Settings.ChatRootPath;\n }\n\n return CandidateChatRoots().FirstOrDefault(root => Directory.Exists(root) && ContainsSessionJsonl(root)) ?? \"\";\n }\n\n public IReadOnlyList CandidateChatRoots()\n {\n var roots = new List();\n var codexHome = Environment.GetEnvironmentVariable(\"CODEX_HOME\");\n if (!string.IsNullOrWhiteSpace(codexHome))\n {\n AddIfUnique(roots, Path.Combine(codexHome, \"sessions\"));\n AddIfUnique(roots, Path.Combine(codexHome, \"archived_sessions\"));\n AddIfUnique(roots, codexHome);\n }\n\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var userCodex = Path.Combine(home, \".codex\");\n AddIfUnique(roots, Path.Combine(userCodex, \"sessions\"));\n AddIfUnique(roots, Path.Combine(userCodex, \"archived_sessions\"));\n AddIfUnique(roots, Path.Combine(userCodex, \"repair-backups\"));\n AddIfUnique(roots, userCodex);\n return roots;\n }\n\n public async Task AutoIndexAsync(IProgress? progress = null)\n {\n if (!Store.Settings.AutoIndexOnStartup)\n {\n progress?.Report(\"Auto-index is off.\");\n return 0;\n }\n\n var root = ResolveDefaultChatRoot();\n if (string.IsNullOrWhiteSpace(root))\n {\n Store.Settings.LastIndexStatus = \"No local Codex sessions were found. Set a chat folder in Settings.\";\n progress?.Report(Store.Settings.LastIndexStatus);\n await SaveAsync();\n return 0;\n }\n\n if (!HasOnlyDemoSessions()\n && !string.IsNullOrWhiteSpace(Store.Settings.LastIndexedAt)\n && string.Equals(Store.Settings.ChatRootPath, root, StringComparison.OrdinalIgnoreCase))\n {\n Store.Settings.LastIndexStatus = $\"Using existing index from {Store.Settings.ChatRootPath}. Use Index folder to refresh.\";\n progress?.Report(Store.Settings.LastIndexStatus);\n return 0;\n }\n\n progress?.Report($\"Indexing {root}\");\n return await IndexRootAsync(root, progress);\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n var changed = await Task.Run(EnrichTitlesFromLocalState);\n if (changed)\n {\n RefreshSessions(Store.Sessions.Values);\n }\n return changed;\n }\n\n public async Task SaveAsync()\n {\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var json = JsonSerializer.Serialize(Store, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null)\n {\n rootPath = Environment.ExpandEnvironmentVariables(rootPath.Trim());\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n\n Store.Settings.ChatRootPath = rootPath;\n var files = EnumerateSessionFiles(rootPath).Take(5000).ToList();\n var indexed = 0;\n if (files.Count > 0 && HasOnlyDemoSessions())\n {\n RemoveDemoSessions();\n }\n\n foreach (var file in files)\n {\n try\n {\n var session = await ParseJsonlAsync(file);\n if (session.Messages.Count == 0)\n {\n continue;\n }\n Store.Sessions[session.Id] = session;\n indexed++;\n if (indexed % 25 == 0) progress?.Report($\"Indexed {indexed}/{files.Count}\");\n }\n catch (Exception ex)\n {\n progress?.Report($\"Skipped {Path.GetFileName(file)}: {ex.Message}\");\n }\n }\n\n Store.Settings.LastIndexedAt = DateTime.UtcNow.ToString(\"O\");\n Store.Settings.LastIndexStatus = indexed == 0\n ? $\"No chat sessions found under {rootPath}.\"\n : $\"Indexed {indexed} chats from {rootPath}.\";\n if (indexed > 0)\n {\n EnrichTitlesFromLocalState();\n }\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n progress?.Report(Store.Settings.LastIndexStatus);\n return indexed;\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n var seen = new HashSet(StringComparer.Ordinal);\n var info = new FileInfo(filePath);\n updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);\n using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);\n var lineCount = 0;\n while (await reader.ReadLineAsync() is { } line)\n {\n lineCount++;\n if (lineCount > MaxLinesPerSession) break;\n if (messages.Count >= MaxMessagesPerSession) break;\n if (string.IsNullOrWhiteSpace(line)) continue;\n if (line.Length > MaxLineChars) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n if (!string.IsNullOrWhiteSpace(timestamp)) updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n if (!IsIndexedRole(role)) continue;\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n else if (payload.TryGetProperty(\"type\", out var eventTypeProp))\n {\n var eventType = eventTypeProp.GetString();\n if (eventType is \"user_message\" or \"agent_message\")\n {\n var role = eventType == \"agent_message\" ? \"assistant\" : \"user\";\n var text = payload.TryGetProperty(\"message\", out var messageProp) ? messageProp.GetString() ?? \"\" : \"\";\n AddMessage(messages, codeBlocks, role, text, timestamp, seen);\n }\n }\n }\n }\n\n if (string.IsNullOrWhiteSpace(created)) created = info.CreationTimeUtc.ToString(\"O\");\n if (string.IsNullOrWhiteSpace(updated)) updated = info.LastWriteTimeUtc.ToString(\"O\");\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static bool IsIndexedRole(string role)\n {\n return role.Equals(\"user\", StringComparison.OrdinalIgnoreCase)\n || role.Equals(\"assistant\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp, HashSet seen)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var duplicateKey = $\"{role}\\n{timestamp}\\n{capped}\";\n if (!seen.Add(duplicateKey)) return;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable EnumerateSessionFiles(string rootPath)\n {\n try\n {\n return Directory.EnumerateFiles(rootPath, \"*.jsonl\", SearchOption.AllDirectories)\n .Where(file => !IsKnownNonSessionJsonl(file))\n .OrderByDescending(file => File.GetLastWriteTimeUtc(file))\n .ToList();\n }\n catch\n {\n return [];\n }\n }\n\n private static bool ContainsSessionJsonl(string rootPath)\n {\n return EnumerateSessionFiles(rootPath).Take(25).Any();\n }\n\n private static bool IsKnownNonSessionJsonl(string filePath)\n {\n var name = Path.GetFileName(filePath);\n return name.Equals(\"session_index.jsonl\", StringComparison.OrdinalIgnoreCase)\n || name.Equals(\"history.jsonl\", StringComparison.OrdinalIgnoreCase)\n || name.Equals(\"responses.jsonl\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddIfUnique(List roots, string path)\n {\n if (!roots.Any(existing => string.Equals(existing, path, StringComparison.OrdinalIgnoreCase)))\n {\n roots.Add(path);\n }\n }\n\n private bool HasOnlyDemoSessions()\n {\n return Store.Sessions.Count > 0 && Store.Sessions.Values.All(IsDemoSession);\n }\n\n private void RemoveDemoSessions()\n {\n foreach (var id in Store.Sessions.Values.Where(IsDemoSession).Select(session => session.Id).ToList())\n {\n Store.Sessions.Remove(id);\n }\n\n foreach (var collection in Store.Collections.Values)\n {\n collection.SessionIds.RemoveAll(id => !Store.Sessions.ContainsKey(id));\n }\n }\n\n private static bool IsDemoSession(ArchiveSession session)\n {\n return session.Model == \"local-demo\"\n || session.SourcePath.StartsWith(\"data/fixtures/\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.StartsWith(\"data\\\\fixtures\\\\\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState()\n {\n var titles = LoadThreadTitles();\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string DefaultStorePath()\n {\n var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);\n return Path.Combine(local, \"CodexLocalRetrieval\", \"app-store.json\");\n }\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "8e390fa1e53063d2e9d131ca0d98f69a5db963ef": {"t": 1785756834.0412762, "n": 34369, "h": "fc33b4fc3b0e7497", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private readonly string _contentRootPath;\n private readonly string _seedStorePath;\n private readonly string _storePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, string? contentRootPath = null)\n {\n _contentRootPath = contentRootPath ?? FindProjectRoot();\n _seedStorePath = Path.Combine(_contentRootPath, \"data\", \"app-store.json\");\n _storePath = storePath ?? DefaultStorePath();\n }\n\n public async Task LoadAsync()\n {\n if (File.Exists(_storePath))\n {\n var json = await File.ReadAllTextAsync(_storePath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n else if (File.Exists(_seedStorePath))\n {\n var json = await File.ReadAllTextAsync(_seedStorePath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public string ResolveDefaultChatRoot()\n {\n if (!string.IsNullOrWhiteSpace(Store.Settings.ChatRootPath) && Directory.Exists(Store.Settings.ChatRootPath))\n {\n return Store.Settings.ChatRootPath;\n }\n\n return CandidateChatRoots().FirstOrDefault(root => Directory.Exists(root) && ContainsSessionJsonl(root)) ?? \"\";\n }\n\n public IReadOnlyList CandidateChatRoots()\n {\n var roots = new List();\n var codexHome = Environment.GetEnvironmentVariable(\"CODEX_HOME\");\n if (!string.IsNullOrWhiteSpace(codexHome))\n {\n AddIfUnique(roots, Path.Combine(codexHome, \"sessions\"));\n AddIfUnique(roots, Path.Combine(codexHome, \"archived_sessions\"));\n AddIfUnique(roots, codexHome);\n }\n\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var userCodex = Path.Combine(home, \".codex\");\n AddIfUnique(roots, Path.Combine(userCodex, \"sessions\"));\n AddIfUnique(roots, Path.Combine(userCodex, \"archived_sessions\"));\n AddIfUnique(roots, Path.Combine(userCodex, \"repair-backups\"));\n AddIfUnique(roots, userCodex);\n return roots;\n }\n\n public async Task AutoIndexAsync(IProgress? progress = null)\n {\n if (!Store.Settings.AutoIndexOnStartup)\n {\n progress?.Report(\"Auto-index is off.\");\n return 0;\n }\n\n var root = ResolveDefaultChatRoot();\n if (string.IsNullOrWhiteSpace(root))\n {\n Store.Settings.LastIndexStatus = \"No local Codex sessions were found. Set a chat folder in Settings.\";\n progress?.Report(Store.Settings.LastIndexStatus);\n await SaveAsync();\n return 0;\n }\n\n if (!HasOnlyDemoSessions()\n && !string.IsNullOrWhiteSpace(Store.Settings.LastIndexedAt)\n && string.Equals(Store.Settings.ChatRootPath, root, StringComparison.OrdinalIgnoreCase))\n {\n Store.Settings.LastIndexStatus = $\"Using existing index from {Store.Settings.ChatRootPath}. Use Index folder to refresh.\";\n progress?.Report(Store.Settings.LastIndexStatus);\n return 0;\n }\n\n progress?.Report($\"Indexing {root}\");\n return await IndexRootAsync(root, progress);\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n var changed = await Task.Run(EnrichTitlesFromLocalState);\n if (changed)\n {\n RefreshSessions(Store.Sessions.Values);\n }\n return changed;\n }\n\n public async Task SaveAsync()\n {\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var json = JsonSerializer.Serialize(Store, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null)\n {\n rootPath = Environment.ExpandEnvironmentVariables(rootPath.Trim());\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n\n Store.Settings.ChatRootPath = rootPath;\n var files = EnumerateSessionFiles(rootPath).Take(5000).ToList();\n var indexed = 0;\n if (files.Count > 0 && HasOnlyDemoSessions())\n {\n RemoveDemoSessions();\n }\n\n foreach (var file in files)\n {\n try\n {\n var session = await ParseJsonlAsync(file);\n if (session.Messages.Count == 0)\n {\n continue;\n }\n Store.Sessions[session.Id] = session;\n indexed++;\n if (indexed % 25 == 0) progress?.Report($\"Indexed {indexed}/{files.Count}\");\n }\n catch (Exception ex)\n {\n progress?.Report($\"Skipped {Path.GetFileName(file)}: {ex.Message}\");\n }\n }\n\n Store.Settings.LastIndexedAt = DateTime.UtcNow.ToString(\"O\");\n Store.Settings.LastIndexStatus = indexed == 0\n ? $\"No chat sessions found under {rootPath}.\"\n : $\"Indexed {indexed} chats from {rootPath}.\";\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n progress?.Report(Store.Settings.LastIndexStatus);\n return indexed;\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n foreach (var line in await File.ReadAllLinesAsync(filePath))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp);\n }\n }\n }\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable EnumerateSessionFiles(string rootPath)\n {\n try\n {\n return Directory.EnumerateFiles(rootPath, \"*.jsonl\", SearchOption.AllDirectories)\n .Where(file => !IsKnownNonSessionJsonl(file))\n .OrderByDescending(file => File.GetLastWriteTimeUtc(file))\n .ToList();\n }\n catch\n {\n return [];\n }\n }\n\n private static bool ContainsSessionJsonl(string rootPath)\n {\n return EnumerateSessionFiles(rootPath).Take(25).Any();\n }\n\n private static bool IsKnownNonSessionJsonl(string filePath)\n {\n var name = Path.GetFileName(filePath);\n return name.Equals(\"session_index.jsonl\", StringComparison.OrdinalIgnoreCase)\n || name.Equals(\"history.jsonl\", StringComparison.OrdinalIgnoreCase)\n || name.Equals(\"responses.jsonl\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void AddIfUnique(List roots, string path)\n {\n if (!roots.Any(existing => string.Equals(existing, path, StringComparison.OrdinalIgnoreCase)))\n {\n roots.Add(path);\n }\n }\n\n private bool HasOnlyDemoSessions()\n {\n return Store.Sessions.Count > 0 && Store.Sessions.Values.All(IsDemoSession);\n }\n\n private void RemoveDemoSessions()\n {\n foreach (var id in Store.Sessions.Values.Where(IsDemoSession).Select(session => session.Id).ToList())\n {\n Store.Sessions.Remove(id);\n }\n\n foreach (var collection in Store.Collections.Values)\n {\n collection.SessionIds.RemoveAll(id => !Store.Sessions.ContainsKey(id));\n }\n }\n\n private static bool IsDemoSession(ArchiveSession session)\n {\n return session.Model == \"local-demo\"\n || session.SourcePath.StartsWith(\"data/fixtures/\", StringComparison.OrdinalIgnoreCase)\n || session.SourcePath.StartsWith(\"data\\\\fixtures\\\\\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState()\n {\n var titles = LoadThreadTitles();\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string DefaultStorePath()\n {\n var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);\n return Path.Combine(local, \"CodexLocalRetrieval\", \"app-store.json\");\n }\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}, "d41198d2e972338b644526073b7267545985d5a8": {"t": 1785756835.8917055, "n": 29443, "h": "5a5a0cc1aa99b25a", "c": "using System.Collections.ObjectModel;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\nusing CodexLocalRetrieval.Core.Models;\nusing Microsoft.Data.Sqlite;\n\nnamespace CodexLocalRetrieval.Core.Services;\n\npublic sealed class ArchiveService\n{\n private readonly string _contentRootPath;\n private readonly string _seedStorePath;\n private readonly string _storePath;\n private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, WriteIndented = true };\n\n public AppStoreData Store { get; private set; } = new();\n public ObservableCollection Sessions { get; } = new();\n\n public ArchiveService(string? storePath = null, string? contentRootPath = null)\n {\n _contentRootPath = contentRootPath ?? FindProjectRoot();\n _seedStorePath = Path.Combine(_contentRootPath, \"data\", \"app-store.json\");\n _storePath = storePath ?? DefaultStorePath();\n }\n\n public async Task LoadAsync()\n {\n if (File.Exists(_storePath))\n {\n var json = await File.ReadAllTextAsync(_storePath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n else if (File.Exists(_seedStorePath))\n {\n var json = await File.ReadAllTextAsync(_seedStorePath);\n Store = JsonSerializer.Deserialize(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task EnrichTitlesFromLocalStateAsync()\n {\n var changed = await Task.Run(EnrichTitlesFromLocalState);\n if (changed)\n {\n RefreshSessions(Store.Sessions.Values);\n }\n return changed;\n }\n\n public async Task SaveAsync()\n {\n Directory.CreateDirectory(Path.GetDirectoryName(_storePath)!);\n var json = JsonSerializer.Serialize(Store, _jsonOptions);\n await File.WriteAllTextAsync(_storePath, json);\n }\n\n public void RefreshSessions(IEnumerable sessions)\n {\n Sessions.Clear();\n foreach (var session in OrderedVisibleSessions(sessions).Take(600))\n {\n if (session.Tags.Count == 0)\n {\n session.Tags.Add(\"archive\");\n if (session.CodeBlocks.Count > 0) session.Tags.Add(\"code\");\n }\n Sessions.Add(session);\n }\n }\n\n public IReadOnlyList Search(string query)\n {\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(Store.Sessions.Values).ToList();\n }\n\n var terms = query.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);\n return Store.Sessions.Values\n .Where(session => !session.Archived)\n .Where(session => terms.All(term => SearchText(session).Contains(term, StringComparison.OrdinalIgnoreCase)))\n .OrderByDescending(session => Score(session, terms))\n .ThenByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt)\n .ToList();\n }\n\n public IReadOnlyList DeepSearch(string query, int limit = 80)\n {\n var visible = Store.Sessions.Values.Where(session => !session.Archived);\n if (string.IsNullOrWhiteSpace(query))\n {\n return OrderedVisibleSessions(visible)\n .Take(limit)\n .Select(session => new ArchiveSearchHit\n {\n Session = session,\n Message = session.Messages.LastOrDefault(),\n SourceLabel = \"recent chat\",\n Snippet = MakeSnippet(session.Messages.LastOrDefault()?.Text ?? session.Text, []),\n Score = session.Pinned ? 5 : 0\n })\n .ToList();\n }\n\n var terms = QueryTerms(query);\n return visible\n .SelectMany(session => DeepHitsForSession(session, terms))\n .OrderByDescending(hit => hit.Score)\n .ThenByDescending(hit => hit.Session.Pinned)\n .ThenByDescending(hit => hit.Session.UpdatedAt)\n .Take(limit)\n .ToList();\n }\n\n public async Task RenameSessionAsync(ArchiveSession session, string title)\n {\n session.CustomTitle = CleanTitle(title);\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task TogglePinAsync(ArchiveSession session)\n {\n session.Pinned = !session.Pinned;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task ArchiveSessionAsync(ArchiveSession session)\n {\n session.Archived = true;\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values);\n }\n\n public async Task AddToCollectionAsync(ArchiveSession session, string collectionName)\n {\n var id = Slug(collectionName);\n if (!Store.Collections.TryGetValue(id, out var collection))\n {\n collection = new ArchiveCollection { Id = id, Name = collectionName, Color = Store.Settings.AccentHex };\n Store.Collections[id] = collection;\n }\n if (!collection.SessionIds.Contains(session.Id)) collection.SessionIds.Add(session.Id);\n await SaveAsync();\n }\n\n public AiProviderSettings EnsureAiProvider(string name, string baseUrl, string model)\n {\n var id = Slug(name);\n if (!Store.Settings.AiProviders.Any(provider => provider.Id == id))\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = id,\n Name = name,\n BaseUrl = baseUrl,\n Model = model,\n Models = string.IsNullOrWhiteSpace(model) ? new List() : new List { model },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n }\n Store.Settings.ActiveAiProviderId = id;\n return Store.Settings.AiProviders.First(provider => provider.Id == id);\n }\n\n public AiProviderSettings? ActiveAiProvider()\n {\n return Store.Settings.AiProviders.FirstOrDefault(provider => provider.Id == Store.Settings.ActiveAiProviderId)\n ?? Store.Settings.AiProviders.FirstOrDefault();\n }\n\n public IReadOnlyList RetrieveForQuestion(string question, int limit = 10)\n {\n return DeepSearch(question, limit);\n }\n\n public string CopyPayload(ArchiveSession session, string mode)\n {\n return mode switch\n {\n \"code\" => session.CodeBlocks.Count == 0\n ? \"No code blocks found.\"\n : string.Join(\"\\n\\n\", session.CodeBlocks.Select(block => $\"```{block.Language}\\n{block.Code}\\n```\")),\n \"path\" => session.SourcePath,\n \"paths\" => $\"Chat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n \"restore\" => RestorePacket(session),\n _ => ResumePrompt(session)\n };\n }\n\n public string RestorePacket(ArchiveSession session)\n {\n var firstUser = session.Messages.FirstOrDefault(m => m.Role == \"user\")?.Text ?? session.Title;\n return string.Join(\"\\n\\n\", new[]\n {\n $\"# Restore Packet: {session.DisplayTitle}\",\n $\"## Goal summary\\n{firstUser}\",\n $\"## Current state\\nLast archived activity: {session.UpdatedAt}\",\n $\"## Important paths\\nChat source: {session.SourcePath}\\nWorkspace: {session.Workspace}\",\n $\"## Code blocks\\n{CopyPayload(session, \"code\")}\",\n \"## Suggested first prompt\\nContinue from this restore packet. Treat source paths as read-only and recover the relevant code, decisions, and next actions.\"\n });\n }\n\n public async Task IndexRootAsync(string rootPath, IProgress? progress = null)\n {\n if (!Directory.Exists(rootPath))\n {\n progress?.Report(\"Root does not exist.\");\n return 0;\n }\n\n var files = Directory.EnumerateFiles(rootPath, \"*.jsonl\", SearchOption.AllDirectories).Take(1000).ToList();\n var indexed = 0;\n foreach (var file in files)\n {\n try\n {\n var session = await ParseJsonlAsync(file);\n Store.Sessions[session.Id] = session;\n indexed++;\n if (indexed % 25 == 0) progress?.Report($\"Indexed {indexed}/{files.Count}\");\n }\n catch (Exception ex)\n {\n progress?.Report($\"Skipped {Path.GetFileName(file)}: {ex.Message}\");\n }\n }\n\n await SaveAsync();\n RefreshSessions(Store.Sessions.Values.OrderByDescending(s => s.UpdatedAt));\n progress?.Report($\"Done: {indexed} indexed.\");\n return indexed;\n }\n\n private async Task ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection();\n var codeBlocks = new ObservableCollection();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n foreach (var line in await File.ReadAllLinesAsync(filePath))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n var timestamp = root.TryGetProperty(\"timestamp\", out var ts) ? ts.GetString() ?? \"\" : \"\";\n if (string.IsNullOrWhiteSpace(created)) created = timestamp;\n updated = timestamp;\n\n if (root.TryGetProperty(\"payload\", out var payload))\n {\n if (payload.TryGetProperty(\"id\", out var idProp)) id = idProp.GetString() ?? id;\n if (payload.TryGetProperty(\"cwd\", out var cwdProp)) cwd = cwdProp.GetString() ?? cwd;\n if (payload.TryGetProperty(\"type\", out var typeProp) && typeProp.GetString() == \"message\")\n {\n var role = payload.TryGetProperty(\"role\", out var roleProp) ? roleProp.GetString() ?? \"message\" : \"message\";\n var text = ExtractContent(payload);\n AddMessage(messages, codeBlocks, role, text, timestamp);\n }\n }\n }\n\n var title = CleanFallbackTitle(messages.FirstOrDefault(m => m.Role == \"user\" && IsTitleCandidate(m.Text))?.Text ?? Path.GetFileNameWithoutExtension(filePath));\n return new ArchiveSession\n {\n Id = id,\n Title = title,\n SourcePath = filePath,\n CreatedAt = created,\n UpdatedAt = updated,\n Workspace = string.IsNullOrWhiteSpace(cwd) ? \"Unknown workspace\" : cwd,\n WorkspaceName = string.IsNullOrWhiteSpace(cwd) ? \"Unknown\" : Path.GetFileName(cwd.TrimEnd('\\\\', '/')),\n Model = \"codex\",\n Messages = messages,\n CodeBlocks = codeBlocks,\n Text = string.Join(\"\\n\\n\", messages.Select(m => m.Text)),\n Tags = new ObservableCollection(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddMessage(ObservableCollection messages, ObservableCollection codeBlocks, string role, string text, string timestamp)\n {\n if (string.IsNullOrWhiteSpace(text)) return;\n var capped = text.Length > 4000 ? text[..4000] + \"\\n\\n[Truncated in native index.]\" : text;\n var blocks = ExtractCodeBlocks(capped);\n foreach (var block in blocks) codeBlocks.Add(block);\n messages.Add(new ArchiveMessage\n {\n Id = Guid.NewGuid().ToString(\"N\")[..12],\n Role = role,\n Text = capped,\n Timestamp = timestamp,\n CodeBlocks = new ObservableCollection(blocks)\n });\n }\n\n private static string ExtractContent(JsonElement payload)\n {\n if (!payload.TryGetProperty(\"content\", out var content)) return \"\";\n if (content.ValueKind == JsonValueKind.String) return content.GetString() ?? \"\";\n if (content.ValueKind != JsonValueKind.Array) return content.ToString();\n var builder = new StringBuilder();\n foreach (var item in content.EnumerateArray())\n {\n if (item.TryGetProperty(\"text\", out var text)) builder.AppendLine(text.GetString());\n else if (item.TryGetProperty(\"input_text\", out var input)) builder.AppendLine(input.GetString());\n else if (item.TryGetProperty(\"output_text\", out var output)) builder.AppendLine(output.GetString());\n }\n return builder.ToString();\n }\n\n private static List ExtractCodeBlocks(string text)\n {\n return Regex.Matches(text, \"```([a-zA-Z0-9_+.-]*)\\\\n([\\\\s\\\\S]*?)```\")\n .Select(match => new CodeBlock\n {\n Language = string.IsNullOrWhiteSpace(match.Groups[1].Value) ? \"text\" : match.Groups[1].Value,\n Code = match.Groups[2].Value.Trim()\n })\n .ToList();\n }\n\n private static bool IsTitleCandidate(string text)\n {\n var value = text.Trim();\n return value.Length > 0 && !value.StartsWith(\"\") && !value.StartsWith(\"\");\n }\n\n private static string CleanTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n return value[..Math.Min(120, value.Length)];\n }\n\n private static string CleanFallbackTitle(string text)\n {\n var value = Regex.Replace(text.Trim(), \"\\\\s+\", \" \");\n value = Regex.Replace(value, @\"^#\\s*Context from my IDE setup:\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^##\\s*Open tabs:\\s*-\\s*\", \"\", RegexOptions.IgnoreCase);\n value = Regex.Replace(value, @\"^.*?\\s*\", \"\", RegexOptions.IgnoreCase | RegexOptions.Singleline);\n if (string.IsNullOrWhiteSpace(value)) value = \"Untitled chat\";\n return CleanTitle(value);\n }\n\n private static string SearchText(ArchiveSession session)\n {\n return $\"{session.Id}\\n{session.DisplayTitle}\\n{session.Title}\\n{session.Text}\\n{session.SourcePath}\\n{session.Workspace}\\n{string.Join(' ', session.Tags)}\";\n }\n\n private static IEnumerable DeepHitsForSession(ArchiveSession session, string[] terms)\n {\n var sessionScore = FuzzyScore($\"{session.DisplayTitle}\\n{session.WorkspaceName}\\n{session.Workspace}\\n{session.SourcePath}\\n{string.Join(' ', session.Tags)}\", terms);\n if (sessionScore > 0)\n {\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = \"title/path/tags\",\n Snippet = MakeSnippet($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n MatchedTerms = MatchedTerms($\"{session.DisplayTitle}\\n{session.Workspace}\\n{session.SourcePath}\", terms),\n Score = sessionScore + 20\n };\n }\n\n foreach (var message in session.Messages)\n {\n var score = FuzzyScore(message.Text, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n Message = message,\n SourceLabel = message.RoleLabel,\n Snippet = MakeSnippet(message.Text, terms),\n MatchedTerms = MatchedTerms(message.Text, terms),\n Score = score\n };\n }\n\n foreach (var block in session.CodeBlocks)\n {\n var score = FuzzyScore(block.Code, terms);\n if (score <= 0) continue;\n yield return new ArchiveSearchHit\n {\n Session = session,\n SourceLabel = string.IsNullOrWhiteSpace(block.Language) ? \"code\" : $\"code:{block.Language}\",\n Snippet = MakeSnippet(block.Code, terms),\n MatchedTerms = MatchedTerms(block.Code, terms),\n Score = score + 8\n };\n }\n }\n\n private static string[] QueryTerms(string query)\n {\n return Regex.Matches(query.ToLowerInvariant(), \"[a-z0-9_./\\\\\\\\:-]+\")\n .Select(match => match.Value)\n .Where(term => term.Length > 1)\n .Distinct()\n .Take(12)\n .ToArray();\n }\n\n private static int FuzzyScore(string text, string[] terms)\n {\n if (terms.Length == 0 || string.IsNullOrWhiteSpace(text)) return 0;\n var normalized = Regex.Replace(text.ToLowerInvariant(), \"\\\\s+\", \" \");\n var score = 0;\n foreach (var term in terms)\n {\n if (normalized.Contains(term))\n {\n score += term.Length >= 5 ? 20 : 12;\n continue;\n }\n\n var compactTerm = Regex.Replace(term, \"[^a-z0-9]\", \"\");\n if (compactTerm.Length >= 4 && IsLooseSubsequence(compactTerm, normalized))\n {\n score += 7;\n continue;\n }\n\n if (compactTerm.Length >= 5 && HasCloseToken(compactTerm, normalized))\n {\n score += 5;\n }\n }\n return score;\n }\n\n private static bool IsLooseSubsequence(string needle, string haystack)\n {\n var index = 0;\n foreach (var ch in haystack)\n {\n if (index < needle.Length && ch == needle[index]) index++;\n if (index == needle.Length) return true;\n }\n return false;\n }\n\n private static bool HasCloseToken(string term, string text)\n {\n foreach (Match match in Regex.Matches(text, \"[a-z0-9_./\\\\\\\\:-]+\"))\n {\n var token = Regex.Replace(match.Value, \"[^a-z0-9]\", \"\");\n if (token.Length < 4 || Math.Abs(token.Length - term.Length) > 2) continue;\n if (LevenshteinDistanceWithin(term, token, 2)) return true;\n }\n return false;\n }\n\n private static bool LevenshteinDistanceWithin(string left, string right, int maxDistance)\n {\n var previous = Enumerable.Range(0, right.Length + 1).ToArray();\n var current = new int[right.Length + 1];\n for (var i = 1; i <= left.Length; i++)\n {\n current[0] = i;\n var best = current[0];\n for (var j = 1; j <= right.Length; j++)\n {\n var cost = left[i - 1] == right[j - 1] ? 0 : 1;\n current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + cost);\n best = Math.Min(best, current[j]);\n }\n if (best > maxDistance) return false;\n (previous, current) = (current, previous);\n }\n return previous[right.Length] <= maxDistance;\n }\n\n private static string MakeSnippet(string text, string[] terms)\n {\n var clean = Regex.Replace(text ?? \"\", \"\\\\s+\", \" \").Trim();\n if (string.IsNullOrWhiteSpace(clean)) return \"\";\n\n var index = -1;\n foreach (var term in terms)\n {\n index = clean.IndexOf(term, StringComparison.OrdinalIgnoreCase);\n if (index >= 0) break;\n }\n if (index < 0) index = 0;\n\n var start = Math.Max(0, index - 120);\n var length = Math.Min(clean.Length - start, 280);\n var snippet = clean.Substring(start, length);\n if (start > 0) snippet = \"... \" + snippet;\n if (start + length < clean.Length) snippet += \" ...\";\n return snippet;\n }\n\n private static string MatchedTerms(string text, string[] terms)\n {\n var matches = terms\n .Where(term => text.Contains(term, StringComparison.OrdinalIgnoreCase))\n .Take(6)\n .ToArray();\n return matches.Length == 0 ? \"fuzzy match\" : string.Join(\", \", matches);\n }\n\n private static int Score(ArchiveSession session, string[] terms)\n {\n var score = 0;\n foreach (var term in terms)\n {\n if (session.DisplayTitle.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 8;\n if (session.CodeBlocks.Any(block => block.Code.Contains(term, StringComparison.OrdinalIgnoreCase))) score += 5;\n if (session.SourcePath.Contains(term, StringComparison.OrdinalIgnoreCase)) score += 3;\n }\n return score;\n }\n\n private string ResumePrompt(ArchiveSession session)\n {\n return $\"Continue this archived work from local context.\\n\\nChat title: {session.DisplayTitle}\\nSource path: {session.SourcePath}\\nWorkspace: {session.Workspace}\\n\\nImportant context:\\n\" +\n string.Join(\"\\n\", session.Messages.TakeLast(6).Select(m => $\"- {m.Role}: {Regex.Replace(m.Text, \"\\\\s+\", \" \")[..Math.Min(260, Regex.Replace(m.Text, \"\\\\s+\", \" \").Length)]}\"));\n }\n\n private bool NormalizeSettings()\n {\n var changed = false;\n if (string.IsNullOrWhiteSpace(Store.Settings.Theme))\n {\n Store.Settings.Theme = \"amoled\";\n changed = true;\n }\n var oldBlueAccent = \"codex-\" + \"blue\";\n if (Store.Settings.Accent is \"mint\" or \"\" or null || Store.Settings.Accent == oldBlueAccent)\n {\n Store.Settings.Accent = \"rose\";\n Store.Settings.AccentHex = \"#fb7185\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.Radius))\n {\n Store.Settings.Radius = \"compact\";\n changed = true;\n }\n if (Store.Settings.PanelRadius == 0)\n {\n Store.Settings.PanelRadius = Store.Settings.Radius == \"compact\" ? 12 : Store.Settings.Radius == \"rounded\" ? 18 : 24;\n changed = true;\n }\n if (Store.Settings.AiProviders.Count == 0)\n {\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"deepseek\",\n Name = \"DeepSeek\",\n BaseUrl = \"https://api.deepseek.com\",\n Model = \"deepseek-v4-flash\",\n Models = new List { \"deepseek-v4-flash\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.AiProviders.Add(new AiProviderSettings\n {\n Id = \"openai\",\n Name = \"OpenAI\",\n BaseUrl = \"https://api.openai.com/v1\",\n Model = \"gpt-4.1-mini\",\n Models = new List { \"gpt-4.1-mini\" },\n Kind = \"openai-compatible\",\n Enabled = true\n });\n Store.Settings.ActiveAiProviderId = \"deepseek\";\n changed = true;\n }\n if (string.IsNullOrWhiteSpace(Store.Settings.ActiveAiProviderId) ||\n Store.Settings.AiProviders.All(provider => provider.Id != Store.Settings.ActiveAiProviderId))\n {\n Store.Settings.ActiveAiProviderId = Store.Settings.AiProviders.First().Id;\n changed = true;\n }\n Store.Settings.ReadOnlySourceMode = true;\n return changed;\n }\n\n private bool EnrichTitlesFromLocalState()\n {\n var titles = LoadThreadTitles();\n if (titles.Count == 0) return false;\n\n var changed = false;\n foreach (var session in Store.Sessions.Values)\n {\n if (!titles.TryGetValue(session.Id, out var title) || string.IsNullOrWhiteSpace(title.Name)) continue;\n var clean = CleanTitle(title.Name);\n if (!string.Equals(session.Title, clean, StringComparison.Ordinal))\n {\n session.Title = clean;\n changed = true;\n }\n if (!string.IsNullOrWhiteSpace(title.UpdatedAt) && string.CompareOrdinal(title.UpdatedAt, session.UpdatedAt) > 0)\n {\n session.UpdatedAt = title.UpdatedAt;\n changed = true;\n }\n }\n return changed;\n }\n\n private Dictionary LoadThreadTitles()\n {\n var result = new Dictionary(StringComparer.OrdinalIgnoreCase);\n foreach (var file in CandidateSessionIndexFiles())\n {\n try\n {\n foreach (var line in File.ReadLines(file))\n {\n if (string.IsNullOrWhiteSpace(line)) continue;\n using var doc = JsonDocument.Parse(line);\n var root = doc.RootElement;\n if (!root.TryGetProperty(\"id\", out var idProp)) continue;\n var id = idProp.GetString();\n if (string.IsNullOrWhiteSpace(id)) continue;\n var name = root.TryGetProperty(\"thread_name\", out var nameProp) ? nameProp.GetString() ?? \"\" : \"\";\n var updated = root.TryGetProperty(\"updated_at\", out var updatedProp) ? updatedProp.GetString() ?? \"\" : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // Damaged index files are expected after repair experiments; ignore and continue.\n }\n }\n\n LoadSqliteThreadTitles(result);\n return result;\n }\n\n private static IEnumerable CandidateSessionIndexFiles()\n {\n var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);\n var root = Path.Combine(home, \".codex\");\n var live = Path.Combine(root, \"session_index.jsonl\");\n if (File.Exists(live)) yield return live;\n var backups = Path.Combine(root, \"repair-backups\");\n if (!Directory.Exists(backups)) yield break;\n foreach (var file in Directory.EnumerateFiles(backups, \"session_index.jsonl\", SearchOption.AllDirectories))\n {\n yield return file;\n }\n }\n\n private static void LoadSqliteThreadTitles(Dictionary result)\n {\n var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), \".codex\", \"state_5.sqlite\");\n if (!File.Exists(path)) return;\n\n try\n {\n var builder = new SqliteConnectionStringBuilder { DataSource = path, Mode = SqliteOpenMode.ReadOnly };\n using var connection = new SqliteConnection(builder.ToString());\n connection.Open();\n using var command = connection.CreateCommand();\n command.CommandText = \"select id, title, updated_at_ms, updated_at from threads\";\n using var reader = command.ExecuteReader();\n while (reader.Read())\n {\n var id = reader.GetString(0);\n var name = reader.GetString(1);\n var updatedMs = reader.IsDBNull(2) ? 0 : reader.GetInt64(2);\n var updatedSeconds = reader.IsDBNull(3) ? 0 : reader.GetInt64(3);\n var updated = updatedMs > 0\n ? DateTimeOffset.FromUnixTimeMilliseconds(updatedMs).UtcDateTime.ToString(\"O\")\n : updatedSeconds > 0\n ? DateTimeOffset.FromUnixTimeSeconds(updatedSeconds).UtcDateTime.ToString(\"O\")\n : \"\";\n PutTitle(result, id, name, updated);\n }\n }\n catch\n {\n // SQLite may be locked or absent on older installs. JSONL titles remain enough for those cases.\n }\n }\n\n private static void PutTitle(Dictionary result, string id, string name, string updatedAt)\n {\n if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name)) return;\n if (!result.TryGetValue(id, out var existing) || string.CompareOrdinal(updatedAt, existing.UpdatedAt) >= 0)\n {\n result[id] = new ThreadTitle(name, updatedAt);\n }\n }\n\n private static IEnumerable OrderedVisibleSessions(IEnumerable sessions)\n {\n return sessions\n .Where(session => !session.Archived)\n .OrderByDescending(session => session.Pinned)\n .ThenByDescending(session => session.UpdatedAt);\n }\n\n private static string Slug(string value)\n {\n var slug = Regex.Replace(value.ToLowerInvariant(), \"[^a-z0-9]+\", \"-\").Trim('-');\n return string.IsNullOrWhiteSpace(slug) ? \"collection\" : slug;\n }\n\n private sealed record ThreadTitle(string Name, string UpdatedAt);\n\n private static string DefaultStorePath()\n {\n var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);\n return Path.Combine(local, \"CodexLocalRetrieval\", \"app-store.json\");\n }\n\n private static string FindProjectRoot()\n {\n var dir = AppContext.BaseDirectory;\n while (!string.IsNullOrWhiteSpace(dir))\n {\n if (File.Exists(Path.Combine(dir, \"data\", \"app-store.json\")) || Directory.Exists(Path.Combine(dir, \"data\", \"fixtures\")))\n {\n return dir;\n }\n var parent = Directory.GetParent(dir)?.FullName;\n if (parent == dir) break;\n dir = parent ?? \"\";\n }\n return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, \"..\", \"..\", \"..\", \"..\", \"..\"));\n }\n}\n"}}}}