Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/asajid2-cell_codex-local-retrieval.json
T

1 line
4.8 MiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string, (DateTime exp, GateOutcome outcome)>();\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<string> _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\\\":\\\"<optional in-app name, omit to keep the auto title>\\\",\\\"id\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? onRemoveSession = null,\n IReadOnlyList<string>? tags = null, Action? onAddTag = null, Action<string>? onRemoveTag = null,\n string? pathLabel = null, Action<ArchiveSession, int, bool>? onMoveSession = null,\n IReadOnlyList<(string Id, string Name)>? moveTargets = null, Action<string>? 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<string>(), 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<int, bool>? 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<int, bool>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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\\\":\\\"<optional in-app name, omit to keep the auto title>\\\",\\\"id\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? onRemoveSession = null,\n IReadOnlyList<string>? tags = null, Action? onAddTag = null, Action<string>? onRemoveTag = null,\n string? pathLabel = null, Action<ArchiveSession, int, bool>? onMoveSession = null,\n IReadOnlyList<(string Id, string Name)>? moveTargets = null, Action<string>? 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<string>(), 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<int, bool>? 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<int, bool>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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\\\":\\\"<optional in-app name, omit to keep the auto title>\\\",\\\"id\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? onRemoveSession = null,\n IReadOnlyList<string>? tags = null, Action? onAddTag = null, Action<string>? onRemoveTag = null,\n string? pathLabel = null, Action<ArchiveSession, int, bool>? onMoveSession = null,\n IReadOnlyList<(string Id, string Name)>? moveTargets = null, Action<string>? 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<string>(), 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<int, bool>? 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<int, bool>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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\\\":\\\"<optional in-app name, omit to keep the auto title>\\\",\\\"id\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? onRemoveSession = null,\n IReadOnlyList<string>? tags = null, Action? onAddTag = null, Action<string>? onRemoveTag = null,\n string? pathLabel = null, Action<ArchiveSession, int, bool>? 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<string>(), 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<int, bool>? 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<int, bool>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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\\\":\\\"<optional in-app name, omit to keep the auto title>\\\",\\\"id\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? onRemoveSession = null,\n IReadOnlyList<string>? tags = null, Action? onAddTag = null, Action<string>? 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<string>(), 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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\\\":\\\"<optional in-app name, omit to keep the auto title>\\\",\\\"id\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? onRemoveSession = null,\n IReadOnlyList<string>? tags = null, Action? onAddTag = null, Action<string>? 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<string>(), 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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\\\":\\\"<optional in-app name, omit to keep the auto title>\\\",\\\"id\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? onRemoveSession = null,\n IReadOnlyList<string>? tags = null, Action? onAddTag = null, Action<string>? 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<string>(), 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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\\\":\\\"<your session id>\\\",\\\"tool\\\":\\\"codex|claude\\\",\\\"requestId\\\":\\\"<uuid>\\\"}}\\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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\\\":\\\"<your session 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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> sessions,\n Action? onDelete = null, Action? onCopyAgentCommand = null, Action<ArchiveSession>? 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<RawEvent> events;\n try { events = await _archive.ReadEventsAsync(session); }\n catch (Exception ex) { Diag.Log(\"ReadEvents: \" + ex.Message); events = Array.Empty<RawEvent>(); }\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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_<ID>_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<string> _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<ArchiveSession>()\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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<string> _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<ArchiveSession>()\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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<string> _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<string>(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<ArchiveSession>()\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<ArchiveSession> 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<string>(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<ComboBoxItem>())\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<AccentPreset> 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<string> _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<ArchiveSession>()\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<ArchiveSession> 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<ComboBoxItem>())\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<AccentPreset> 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<ChatMessage> _copilotApi = new();\n private readonly List<Window> _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<int>();\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<bool> 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<ChatMessage> _copilotApi = new();\n private readonly List<Window> _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<int>();\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<bool> 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<ChatMessage> _copilotApi = new();\n private readonly List<Window> _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<int>();\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<bool> 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<ChatMessage> _copilotApi = new();\n private readonly List<Window> _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<int>();\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<bool> 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<ChatMessage> _copilotApi = new();\n private readonly List<Window> _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<int>();\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<bool> 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<ChatMessage> _copilotApi = new();\n private readonly List<Window> _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<bool> 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<ChatMessage> _copilotApi = new();\n private readonly List<Window> _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<bool> 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<CopilotTurn>? 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<IChatBackend?> _backendFactory;\n private readonly bool _redactReads;\n private readonly bool _allowLaunch;\n\n public RemoteApi(ArchiveService archive, Func<IChatBackend?> 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<object> CopilotAsync(string message, List<CopilotTurn>? 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> { 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<object> 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<CopilotTurn>? 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<IChatBackend?> _backendFactory;\n private readonly bool _redactReads;\n private readonly bool _allowLaunch;\n\n public RemoteApi(ArchiveService archive, Func<IChatBackend?> 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<object> CopilotAsync(string message, List<CopilotTurn>? 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> { 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<object> 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<CopilotTurn>? 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<IChatBackend?> _backendFactory;\n private readonly bool _redactReads;\n private readonly bool _allowLaunch;\n\n public RemoteApi(ArchiveService archive, Func<IChatBackend?> 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<object> CopilotAsync(string message, List<CopilotTurn>? 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> { 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<object> 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<TimeSpan, CancellationToken, Task> _delay;\n\n public DeepSeekBackend(string baseUrl, string model, string apiKey, HttpClient? http = null, int maxTokens = 2000,\n int maxAttempts = 3, Func<TimeSpan, CancellationToken, Task>? 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<BackendReply> CompleteAsync(IReadOnlyList<ChatMessage> messages, IReadOnlyList<ChatToolSpec> 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<string, object?>\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<ToolCall>? toolCalls = null;\n if (message.TryGetProperty(\"tool_calls\", out var tc) && tc.ValueKind == JsonValueKind.Array && tc.GetArrayLength() > 0)\n {\n toolCalls = new List<ToolCall>();\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<TimeSpan, CancellationToken, Task> _delay;\n\n public DeepSeekBackend(string baseUrl, string model, string apiKey, HttpClient? http = null, int maxTokens = 2000,\n int maxAttempts = 3, Func<TimeSpan, CancellationToken, Task>? 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<BackendReply> CompleteAsync(IReadOnlyList<ChatMessage> messages, IReadOnlyList<ChatToolSpec> 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<string, object?>\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<ToolCall>? toolCalls = null;\n if (message.TryGetProperty(\"tool_calls\", out var tc) && tc.ValueKind == JsonValueKind.Array && tc.GetArrayLength() > 0)\n {\n toolCalls = new List<ToolCall>();\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<BackendReply> CompleteAsync(IReadOnlyList<ChatMessage> messages, IReadOnlyList<ChatToolSpec> 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<string, object?>\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<ToolCall>? toolCalls = null;\n if (message.TryGetProperty(\"tool_calls\", out var tc) && tc.ValueKind == JsonValueKind.Array && tc.GetArrayLength() > 0)\n {\n toolCalls = new List<ToolCall>();\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<BackendReply> CompleteAsync(IReadOnlyList<ChatMessage> messages, IReadOnlyList<ChatToolSpec> 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<string, object?>\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<ToolCall>? toolCalls = null;\n if (message.TryGetProperty(\"tool_calls\", out var tc) && tc.ValueKind == JsonValueKind.Array && tc.GetArrayLength() > 0)\n {\n toolCalls = new List<ToolCall>();\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<ArchiveSession> 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<AppStoreData>(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<ArchiveMessage>\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<CodeBlock>();\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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n Decks = new List<Deck>(Store.Decks),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(Store.FileStamps),\n TagColors = new Dictionary<string, string>(Store.TagColors),\n TagLayers = new Dictionary<string, int>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<Deck> 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<ArchiveCollection> 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<Deck> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string>()).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<ArchiveSession?> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string> ReservedTags =\n new HashSet<string>(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<string> 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<bool> 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<bool> 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<bool> 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<bool> 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<TagCount> 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<TagCount> 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<ArchiveSession> Filter(string query, IReadOnlyCollection<string>? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty<string>()).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<ArchiveSession> FilterChats(ChatFilter f)\n {\n IEnumerable<ArchiveSession> 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<string>(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<ArchiveCollection> FilterCollections(IReadOnlyCollection<string> include, IReadOnlyCollection<string> 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<ArchiveSession> OrderCollectionChats(ArchiveCollection col, IEnumerable<ArchiveSession> sessions)\n {\n var manual = new Dictionary<string, int>(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<string> 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<string> 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<ArchiveMessage> Messages,\n ObservableCollection<CodeBlock> CodeBlocks,\n string Id, string Title, string Created, string Updated,\n string Cwd, HashSet<string> 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<IReadOnlyList<FullMessage>> ParseFullAsync(ArchiveSession session)\n {\n if (session is null || string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath))\n return Array.Empty<FullMessage>();\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<FullMessage>();\n var list = new List<FullMessage>(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<IReadOnlyList<FullMessage>> 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<FullMessage>(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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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 <id>`\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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ParsedTranscript> ParseCodexCoreAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<ArchiveSession> 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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ParsedTranscript?> ParseClaudeCoreAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(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<ArchiveSession?> 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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<ArchiveMessage>\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<CodeBlock>();\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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n Decks = new List<Deck>(Store.Decks),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(Store.FileStamps),\n TagColors = new Dictionary<string, string>(Store.TagColors),\n TagLayers = new Dictionary<string, int>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<Deck> 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<ArchiveCollection> 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<Deck> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string>()).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<ArchiveSession?> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string> ReservedTags =\n new HashSet<string>(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<string> 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<bool> 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<bool> 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<bool> 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<bool> 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<TagCount> 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<TagCount> 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<ArchiveSession> Filter(string query, IReadOnlyCollection<string>? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty<string>()).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<ArchiveSession> FilterChats(ChatFilter f)\n {\n IEnumerable<ArchiveSession> 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<string>(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<ArchiveCollection> FilterCollections(IReadOnlyCollection<string> include, IReadOnlyCollection<string> 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<ArchiveSession> OrderCollectionChats(ArchiveCollection col, IEnumerable<ArchiveSession> sessions)\n {\n var manual = new Dictionary<string, int>(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<string> 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<string> 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<ArchiveMessage> Messages,\n ObservableCollection<CodeBlock> CodeBlocks,\n string Id, string Title, string Created, string Updated,\n string Cwd, HashSet<string> 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<IReadOnlyList<FullMessage>> ParseFullAsync(ArchiveSession session)\n {\n if (session is null || string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath))\n return Array.Empty<FullMessage>();\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<FullMessage>();\n var list = new List<FullMessage>(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<IReadOnlyList<FullMessage>> 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<FullMessage>(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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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 <id>`\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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ParsedTranscript> ParseCodexCoreAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<ArchiveSession> 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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ParsedTranscript?> ParseClaudeCoreAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(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<ArchiveSession?> 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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<ArchiveMessage>\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<CodeBlock>();\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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n Decks = new List<Deck>(Store.Decks),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(Store.FileStamps),\n TagColors = new Dictionary<string, string>(Store.TagColors),\n TagLayers = new Dictionary<string, int>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<Deck> 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<ArchiveCollection> 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<Deck> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string>()).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<ArchiveSession?> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string> ReservedTags =\n new HashSet<string>(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<string> 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<bool> 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<bool> 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<bool> 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<bool> 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<TagCount> 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<TagCount> 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<ArchiveSession> Filter(string query, IReadOnlyCollection<string>? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty<string>()).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<ArchiveSession> FilterChats(ChatFilter f)\n {\n IEnumerable<ArchiveSession> 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<string>(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<ArchiveCollection> FilterCollections(IReadOnlyCollection<string> include, IReadOnlyCollection<string> 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<ArchiveSession> OrderCollectionChats(ArchiveCollection col, IEnumerable<ArchiveSession> sessions)\n {\n var manual = new Dictionary<string, int>(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<string> 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<string> 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<ArchiveMessage> Messages,\n ObservableCollection<CodeBlock> CodeBlocks,\n string Id, string Title, string Created, string Updated,\n string Cwd, HashSet<string> 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<IReadOnlyList<FullMessage>> ParseFullAsync(ArchiveSession session)\n {\n if (session is null || string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath))\n return Array.Empty<FullMessage>();\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<FullMessage>();\n var list = new List<FullMessage>(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<IReadOnlyList<FullMessage>> 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<FullMessage>(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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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 <id>`\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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ParsedTranscript> ParseCodexCoreAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<ArchiveSession> 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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ParsedTranscript?> ParseClaudeCoreAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(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<ArchiveSession?> 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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<ArchiveMessage>\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<CodeBlock>();\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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n Decks = new List<Deck>(Store.Decks),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(Store.FileStamps),\n TagColors = new Dictionary<string, string>(Store.TagColors),\n TagLayers = new Dictionary<string, int>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<Deck> 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<ArchiveCollection> 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<Deck> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string>()).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<ArchiveSession?> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string> ReservedTags =\n new HashSet<string>(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<string> 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<bool> 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<bool> 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<bool> 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<bool> 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<TagCount> 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<TagCount> 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<ArchiveSession> Filter(string query, IReadOnlyCollection<string>? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty<string>()).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<ArchiveSession> FilterChats(ChatFilter f)\n {\n IEnumerable<ArchiveSession> 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<string>(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<ArchiveCollection> FilterCollections(IReadOnlyCollection<string> include, IReadOnlyCollection<string> 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<ArchiveSession> OrderCollectionChats(ArchiveCollection col, IEnumerable<ArchiveSession> sessions)\n {\n var manual = new Dictionary<string, int>(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<string> 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<string> 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<ArchiveMessage> Messages,\n ObservableCollection<CodeBlock> CodeBlocks,\n string Id, string Title, string Created, string Updated,\n string Cwd, HashSet<string> 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<IReadOnlyList<FullMessage>> ParseFullAsync(ArchiveSession session)\n {\n if (session is null || string.IsNullOrWhiteSpace(session.SourcePath) || !File.Exists(session.SourcePath))\n return Array.Empty<FullMessage>();\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<FullMessage>();\n var list = new List<FullMessage>(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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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 <id>`\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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ParsedTranscript> ParseCodexCoreAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<ArchiveSession> 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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ParsedTranscript?> ParseClaudeCoreAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(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<ArchiveSession?> 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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<ArchiveMessage>\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<CodeBlock>();\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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n Decks = new List<Deck>(Store.Decks),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(Store.FileStamps),\n TagColors = new Dictionary<string, string>(Store.TagColors),\n TagLayers = new Dictionary<string, int>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<Deck> 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<ArchiveCollection> 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<Deck> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string>()).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<ArchiveSession?> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string> ReservedTags =\n new HashSet<string>(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<string> 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<bool> 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<bool> 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<bool> 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<bool> 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<TagCount> 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<TagCount> 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<ArchiveSession> Filter(string query, IReadOnlyCollection<string>? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty<string>()).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<ArchiveSession> FilterChats(ChatFilter f)\n {\n IEnumerable<ArchiveSession> 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<string>(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<ArchiveCollection> FilterCollections(IReadOnlyCollection<string> include, IReadOnlyCollection<string> 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<ArchiveSession> OrderCollectionChats(ArchiveCollection col, IEnumerable<ArchiveSession> sessions)\n {\n var manual = new Dictionary<string, int>(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<string> 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<string> 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<ArchiveMessage>\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<CodeBlock>();\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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(Store.FileStamps),\n TagColors = new Dictionary<string, string>(Store.TagColors),\n TagLayers = new Dictionary<string, int>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string>()).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<ArchiveSession?> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string> ReservedTags =\n new HashSet<string>(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<string> 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<bool> 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<bool> 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<bool> 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<bool> 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<TagCount> 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<TagCount> 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<ArchiveSession> Filter(string query, IReadOnlyCollection<string>? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty<string>()).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<ArchiveSession> FilterChats(ChatFilter f)\n {\n IEnumerable<ArchiveSession> 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<string>(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<ArchiveCollection> FilterCollections(IReadOnlyCollection<string> include, IReadOnlyCollection<string> 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<ArchiveSession> OrderCollectionChats(ArchiveCollection col, IEnumerable<ArchiveSession> sessions)\n {\n var manual = new Dictionary<string, int>(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<string> 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<string> 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<ArchiveMessage>\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<CodeBlock>();\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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(Store.FileStamps),\n TagColors = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string>()).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<ArchiveSession?> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string> ReservedTags =\n new HashSet<string>(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<string> 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<bool> 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<bool> 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<bool> 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<bool> 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<TagCount> 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<TagCount> 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<ArchiveSession> Filter(string query, IReadOnlyCollection<string>? tags, bool matchAll = false)\n => FilterChats(new ChatFilter { Query = query ?? \"\", IncludeTags = (tags ?? Array.Empty<string>()).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<ArchiveSession> FilterChats(ChatFilter f)\n {\n IEnumerable<ArchiveSession> 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<string>(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<string> 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<string> 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<ArchiveMessage>\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<CodeBlock>();\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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string>()).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<ArchiveSession?> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string> ReservedTags =\n new HashSet<string>(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<string> 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<bool> 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<bool> 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<bool> 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<bool> 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<TagCount> 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<TagCount> 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<ArchiveSession> Filter(string query, IReadOnlyCollection<string>? tags, bool matchAll = false)\n {\n IEnumerable<ArchiveSession> 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<string> 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static (ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string cwd, string summary, string updated)\n ParseClaudeTail(string path, FileInfo info)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<bool> 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<string> { 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<string> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var fallbackMessages = new ObservableCollection<ArchiveMessage>();\n var fallbackCodeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n var seen = new HashSet<string>(StringComparer.Ordinal);\n var fallbackSeen = new HashSet<string>(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<ArchiveMessage>(merged);\n codeBlocks = new ObservableCollection<CodeBlock>(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<string>(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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddAlias(HashSet<string> 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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(StringComparer.Ordinal);\n var toolUseById = new Dictionary<string, ArchiveMessage>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks,\n HashSet<string> seen, Dictionary<string, ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> 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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<ArchiveSession?> 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<ArchiveSession?> 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\\\":\\\"<that 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<ArchiveSession> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n DeletedCollections = new List<DeletedCollection>(Store.DeletedCollections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<int> ImportCollectionsJsonAsync(string json)\n {\n CollectionsBackup? backup;\n try { backup = JsonSerializer.Deserialize<CollectionsBackup>(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<CollectionsBackup>(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<int> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<ArchiveSession> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<ArchiveSession> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n KeepRecentWindow(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks)\n {\n if (messages.Count <= RecentMessageWindow) return (messages, codeBlocks);\n var keep = messages.Skip(messages.Count - RecentMessageWindow).ToList();\n return (new ObservableCollection<ArchiveMessage>(keep),\n new ObservableCollection<CodeBlock>(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<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<ArchiveSession> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<ArchiveCollection> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<IReadOnlyList<RawEvent>> ReadEventsAsync(ArchiveSession session, int limit = 400)\n => Task.Run(() => ReadEvents(session, limit));\n\n public IReadOnlyList<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(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<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(fs, _jsonOptions) ?? new AppStoreData();\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task<bool> 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<string, ArchiveSession>(Store.Sessions),\n Collections = new Dictionary<string, ArchiveCollection>(Store.Collections),\n FileStamps = new Dictionary<string, string>(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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(fs, _jsonOptions) ?? new AppStoreData();\n }) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task<bool> 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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task<bool> 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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task<bool> 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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<bool> 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<bool> 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<bool> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task<bool> 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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task<bool> 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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<AgentCommandResult> 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<string?> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<string, string>(StringComparer.OrdinalIgnoreCase), progress);\n return await MergeScanAsync(new DiskScan(parsed, new List<ArchiveSession>()) { 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<SessionSource> 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<SessionSource> 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<int> SyncFromDiskAsync(IProgress<string>? 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<DiskScan> ScanDiskAsync(IProgress<string>? progress = null)\n {\n var bundled = Store.Settings.BundledHistoryAbsorbed ? new List<ArchiveSession>() : 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<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(Store.FileStamps, StringComparer.OrdinalIgnoreCase);\n var disk = new List<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession> Parsed, Dictionary<string, string> Stamps)> ParseSourceAsync(\n SessionSource src, IReadOnlyDictionary<string, string> known, IProgress<string>? 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<ArchiveSession>();\n var stamps = new Dictionary<string, string>(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<ArchiveSession?> 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<int> MergeScanAsync(DiskScan scan, bool refreshList = true)\n {\n var imported = new HashSet<string>(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<string>(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<ArchiveSession> LoadBundledHistory(IProgress<string>? progress)\n {\n var list = new List<ArchiveSession>();\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<AppStoreData>(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 <id>`; Codex uses `codex resume --include-non-interactive\n // <id>` (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<RawEvent> ReadEvents(ArchiveSession session, int limit = 400)\n {\n var events = new List<RawEvent>();\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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n\n var seen = new HashSet<string>(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<string>(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<ArchiveSession?> ParseClaudeSessionAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(blocks)\n });\n }\n\n private void RemoveBundledSampleSessions(HashSet<string> 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]*?</\\\\1>\",\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<ArchiveMessage> 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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> 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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(json, _jsonOptions) ?? new AppStoreData();\n }\n else if (File.Exists(_seedStorePath))\n {\n var json = await File.ReadAllTextAsync(_seedStorePath);\n Store = JsonSerializer.Deserialize<AppStoreData>(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<string> CandidateChatRoots()\n {\n var roots = new List<string>();\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<int> AutoIndexAsync(IProgress<string>? 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<bool> 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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\n var id = Path.GetFileNameWithoutExtension(filePath);\n var cwd = \"\";\n var created = \"\";\n var updated = \"\";\n var seen = new HashSet<string>(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<string>(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<ArchiveMessage> messages, ObservableCollection<CodeBlock> codeBlocks, string role, string text, string timestamp, HashSet<string> 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<CodeBlock>(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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<string> 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<string> 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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(json, _jsonOptions) ?? new AppStoreData();\n }\n else if (File.Exists(_seedStorePath))\n {\n var json = await File.ReadAllTextAsync(_seedStorePath);\n Store = JsonSerializer.Deserialize<AppStoreData>(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<string> CandidateChatRoots()\n {\n var roots = new List<string>();\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<int> AutoIndexAsync(IProgress<string>? 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<bool> 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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddMessage(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> 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<CodeBlock>(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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<string> 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<string> 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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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<ArchiveSession> 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<AppStoreData>(json, _jsonOptions) ?? new AppStoreData();\n }\n else if (File.Exists(_seedStorePath))\n {\n var json = await File.ReadAllTextAsync(_seedStorePath);\n Store = JsonSerializer.Deserialize<AppStoreData>(json, _jsonOptions) ?? new AppStoreData();\n }\n NormalizeSettings();\n RefreshSessions(OrderedVisibleSessions(Store.Sessions.Values));\n }\n\n public async Task<bool> 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<ArchiveSession> 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<ArchiveSession> 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<ArchiveSearchHit> 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<string>() : new List<string> { 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<ArchiveSearchHit> 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<int> IndexRootAsync(string rootPath, IProgress<string>? 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<ArchiveSession> ParseJsonlAsync(string filePath)\n {\n var messages = new ObservableCollection<ArchiveMessage>();\n var codeBlocks = new ObservableCollection<CodeBlock>();\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<string>(codeBlocks.Count > 0 ? new[] { \"archive\", \"code\" } : new[] { \"archive\" })\n };\n }\n\n private static void AddMessage(ObservableCollection<ArchiveMessage> messages, ObservableCollection<CodeBlock> 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<CodeBlock>(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<CodeBlock> 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(\"<environment_context>\") && !value.StartsWith(\"<goal_context>\");\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, @\"^<environment_context>.*?</environment_context>\\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<ArchiveSearchHit> 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<string> { \"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<string> { \"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<string, ThreadTitle> LoadThreadTitles()\n {\n var result = new Dictionary<string, ThreadTitle>(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<string> 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<string, ThreadTitle> 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<string, ThreadTitle> 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<ArchiveSession> OrderedVisibleSessions(IEnumerable<ArchiveSession> 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"}}}}