codee-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. codee/.gitignore +2 -0
  2. codee/__init__.py +0 -0
  3. codee/admin.py +1672 -0
  4. codee/admin_api.py +61 -0
  5. codee/admin_cli.py +86 -0
  6. codee/admin_service.py +1005 -0
  7. codee/executor.py +381 -0
  8. codee/init_cli.py +82 -0
  9. codee/lib/__init__.py +0 -0
  10. codee/lib/cron_describe.py +33 -0
  11. codee/lib/runs_db.py +195 -0
  12. codee/lib/test_runs_db.py +199 -0
  13. codee/lib/test_trigger_cron_skills.py +485 -0
  14. codee/lib/test_trigger_issue_skills.py +93 -0
  15. codee/lib/trigger_aws_sqs_skills.py +224 -0
  16. codee/lib/trigger_cron_skills.py +364 -0
  17. codee/lib/trigger_email_skills.py +225 -0
  18. codee/lib/trigger_issue_skills.py +107 -0
  19. codee/mail_server.py +45 -0
  20. codee/start_cli.py +98 -0
  21. codee/templates/AGENTS.md +42 -0
  22. codee/templates/CLAUDE.md +1 -0
  23. codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
  24. codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
  25. codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
  26. codee/templates/skills/story-developer/SKILL.md +26 -0
  27. codee/templates/skills/story-planner/SKILL.md +35 -0
  28. codee/templates/skills/story-planner/assets/readme-template.md +43 -0
  29. codee/templates/skills/story-qa/SKILL.md +28 -0
  30. codee/templates/skills/task-developer/SKILL.md +25 -0
  31. codee/templates/skills/task-qa/SKILL.md +26 -0
  32. codee/test_admin_api.py +64 -0
  33. codee/test_admin_cli.py +63 -0
  34. codee/test_admin_service.py +897 -0
  35. codee/test_executor.py +190 -0
  36. codee/test_init_cli.py +131 -0
  37. codee/test_memory_index.py +31 -0
  38. codee/test_start_cli.py +164 -0
  39. codee/workflow_graph.py +83 -0
  40. codee_admin/__init__.py +1 -0
  41. codee_admin/codee_admin.py +4 -0
  42. codee_agent-0.1.0.dist-info/METADATA +66 -0
  43. codee_agent-0.1.0.dist-info/RECORD +69 -0
  44. codee_agent-0.1.0.dist-info/WHEEL +4 -0
  45. codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
  46. codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. codee_agent_abstract/__init__.py +0 -0
  48. codee_agent_abstract/provider.py +56 -0
  49. codee_agent_claude_code/__init__.py +0 -0
  50. codee_agent_claude_code/provider.py +90 -0
  51. codee_agent_github_copilot/__init__.py +0 -0
  52. codee_agent_github_copilot/provider.py +253 -0
  53. codee_agent_github_copilot/test.py +176 -0
  54. codee_database/__init__.py +0 -0
  55. codee_database/database.py +13 -0
  56. codee_database/oauth_tokens.py +148 -0
  57. codee_main_context/__init__.py +0 -0
  58. codee_main_context/context.py +127 -0
  59. codee_main_context/logging.py +111 -0
  60. codee_main_context/test_logging.py +90 -0
  61. codee_tasks_abstract/__init__.py +0 -0
  62. codee_tasks_abstract/provider.py +58 -0
  63. codee_tasks_azure_devops/__init__.py +0 -0
  64. codee_tasks_azure_devops/oauth.py +346 -0
  65. codee_tasks_azure_devops/provider.py +207 -0
  66. codee_tasks_azure_devops/test.py +462 -0
  67. codee_tasks_jira/__init__.py +0 -0
  68. codee_tasks_jira/provider.py +162 -0
  69. codee_tasks_jira/test.py +75 -0
codee/admin.py ADDED
@@ -0,0 +1,1672 @@
1
+ """Reflex UI for managing Codee."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Any
6
+
7
+ import reflex as rx
8
+ from pydantic import BaseModel
9
+
10
+ from codee.admin_api import api_app
11
+ from codee.admin_service import AGENTS_FILE, AdminService, ISSUE_TYPES, SKILL_TYPES
12
+ from codee.workflow_graph import workflow_graph
13
+
14
+ SERVICE = AdminService()
15
+
16
+ RUNS_PAGE_SIZE = 20
17
+
18
+
19
+ def _save_toast(persisted: bool, pushed: bool, message: str) -> Any:
20
+ """Warn instead of erroring when the change landed on disk but not in Git."""
21
+ if not persisted:
22
+ return rx.toast.error(message)
23
+ return rx.toast.success(message) if pushed else rx.toast.warning(message)
24
+
25
+
26
+ class SkillSummary(BaseModel):
27
+ slug: str
28
+ name: str
29
+ description: str
30
+ type: str
31
+ issue_status: str = ""
32
+ issue_type: str = ""
33
+
34
+
35
+ class ModelOption(BaseModel):
36
+ """One entry in the skill editor's model picker: code plus friendly name."""
37
+
38
+ id: str
39
+ name: str
40
+
41
+
42
+ class MemoryEntry(BaseModel):
43
+ title: str
44
+ file: str
45
+ hook: str
46
+ lineno: int
47
+ raw: str
48
+ matched: bool
49
+
50
+
51
+ class ActiveJob(BaseModel):
52
+ message: str
53
+ elapsed_label: str
54
+ viewer_url: str
55
+
56
+
57
+ class RunRecord(BaseModel):
58
+ skill_name: str
59
+ trigger_type: str
60
+ status: str
61
+ error: str
62
+ started_at: str
63
+ message: str
64
+ preview: str
65
+ viewer_url: str
66
+
67
+
68
+ class AdminState(rx.State):
69
+ skills: list[SkillSummary] = []
70
+ skill_query: str = ""
71
+ skill_filter: str = "All"
72
+ new_skill_name: str = ""
73
+ selected_skill: str = ""
74
+ skill_name: str = ""
75
+ skill_description: str = ""
76
+ skill_model: str = ""
77
+ skill_type: str = "knowledge"
78
+ skill_cron: str = "0 0 * * *"
79
+ skill_email: str = ""
80
+ skill_sqs: str = ""
81
+ skill_issue_status: str = ""
82
+ skill_issue_type: str = "story"
83
+ skill_body: str = ""
84
+ skill_extra: str = ""
85
+ skill_extra_enabled: bool = False
86
+
87
+ agent_models: list[ModelOption] = []
88
+ model_query: str = ""
89
+ models_loading: bool = False
90
+
91
+ editing_agents: bool = False
92
+ agents_content: str = ""
93
+
94
+ memories: list[MemoryEntry] = []
95
+ selected_memory: str = ""
96
+ memory_content: str = ""
97
+
98
+ active_jobs: list[ActiveJob] = []
99
+ total_runs: int = 0
100
+ last_24h_runs: int = 0
101
+ hourly_runs: list[dict[str, Any]] = []
102
+ dashboard_polling: bool = False
103
+
104
+ runs: list[RunRecord] = []
105
+ runs_has_more: bool = False
106
+ runs_loading: bool = False
107
+ session_viewer: str = SERVICE.session_viewer
108
+
109
+ story_workflow_nodes: list[dict[str, Any]] = []
110
+ story_workflow_edges: list[dict[str, Any]] = []
111
+ story_workflow_warnings: list[str] = []
112
+ task_workflow_nodes: list[dict[str, Any]] = []
113
+ task_workflow_edges: list[dict[str, Any]] = []
114
+ task_workflow_warnings: list[str] = []
115
+ workflow_error: str = ""
116
+ workflow_loading: bool = False
117
+ edge_menu_skills: list[str] = []
118
+ edge_menu_left: str = "0px"
119
+ edge_menu_top: str = "0px"
120
+
121
+ tasks_provider: str = "jira"
122
+ coding_agent: str = "claude_code"
123
+ max_parallel_agents: str = "3"
124
+ jira_base_url: str = ""
125
+ jira_account_email: str = ""
126
+ jira_api_token: str = ""
127
+ jira_project: str = ""
128
+ azure_organization_url: str = ""
129
+ azure_project: str = ""
130
+ azure_tenant_id: str = ""
131
+ azure_client_id: str = ""
132
+ azure_client_secret: str = ""
133
+ azure_connected: bool = False
134
+ azure_account: str = ""
135
+ azure_expires_label: str = ""
136
+ azure_redirect_uri: str = ""
137
+
138
+ @rx.var
139
+ def filtered_skills(self) -> list[SkillSummary]:
140
+ query = self.skill_query.strip().lower()
141
+ return [
142
+ skill for skill in self.skills
143
+ if (not query or query in f"{skill.name} {skill.description}".lower())
144
+ and (self.skill_filter == "All" or skill.type == self.skill_filter)
145
+ ]
146
+
147
+ @rx.var
148
+ def agents_card_visible(self) -> bool:
149
+ """AGENTS.md is not a skill, so only an unfiltered search can hide it."""
150
+ return (self.skill_filter == "All"
151
+ and self.skill_query.strip().lower() in AGENTS_FILE.lower())
152
+
153
+ @rx.var
154
+ def cron_description(self) -> str:
155
+ return SERVICE.describe_cron(self.skill_cron)
156
+
157
+ @rx.var
158
+ def filtered_models(self) -> list[ModelOption]:
159
+ query = self.model_query.strip().lower()
160
+ return [
161
+ model for model in self.agent_models
162
+ if not query or query in f"{model.name} {model.id}".lower()
163
+ ]
164
+
165
+ @rx.var
166
+ def skill_model_label(self) -> str:
167
+ """Friendly name of the selected model, falling back to the raw code."""
168
+ if not self.skill_model:
169
+ return "Agent default"
170
+ for model in self.agent_models:
171
+ if model.id == self.skill_model:
172
+ return model.name
173
+ return self.skill_model
174
+
175
+ @rx.var
176
+ def custom_model_query(self) -> str:
177
+ """The search text when it names no known model, so it can be used as-is."""
178
+ query = self.model_query.strip()
179
+ if not query or any(model.id == query for model in self.agent_models):
180
+ return ""
181
+ return query
182
+
183
+ @rx.var
184
+ def active_route(self) -> str:
185
+ return self.router.url.path.rstrip("/") or "/"
186
+
187
+ def set_skill_query(self, value: str) -> None:
188
+ self.skill_query = value
189
+
190
+ def set_skill_filter(self, value: str) -> None:
191
+ self.skill_filter = value
192
+
193
+ def set_new_skill_name(self, value: str) -> None:
194
+ self.new_skill_name = value
195
+
196
+ def set_skill_name(self, value: str) -> None:
197
+ self.skill_name = value
198
+
199
+ def set_skill_description(self, value: str) -> None:
200
+ self.skill_description = value
201
+
202
+ def set_skill_type(self, value: str) -> None:
203
+ self.skill_type = value
204
+
205
+ def set_model_query(self, value: str) -> None:
206
+ self.model_query = value
207
+
208
+ def choose_model(self, model_id: str) -> None:
209
+ """Pick a model from the list, or use whatever the user typed."""
210
+ self.skill_model = model_id.strip()
211
+ self.model_query = ""
212
+
213
+ @rx.event(background=True)
214
+ async def load_agent_models(self) -> None:
215
+ """Fetch the configured agent's catalog off the event loop.
216
+
217
+ Asking an agent can mean spawning its CLI, so this runs in the
218
+ background while the skill list renders; the picker still accepts a
219
+ hand-typed model code if the list never arrives.
220
+ """
221
+ async with self:
222
+ if self.models_loading:
223
+ return
224
+ self.models_loading = True
225
+ try:
226
+ models = await asyncio.to_thread(SERVICE.list_agent_models)
227
+ except Exception:
228
+ models = []
229
+ async with self:
230
+ self.agent_models = [ModelOption(**model) for model in models]
231
+ self.models_loading = False
232
+
233
+ def set_skill_cron(self, value: str) -> None:
234
+ self.skill_cron = value
235
+
236
+ def set_skill_email(self, value: str) -> None:
237
+ self.skill_email = value
238
+
239
+ def set_skill_sqs(self, value: str) -> None:
240
+ self.skill_sqs = value
241
+
242
+ def set_skill_issue_status(self, value: str) -> None:
243
+ self.skill_issue_status = value
244
+
245
+ def set_skill_issue_type(self, value: str) -> None:
246
+ self.skill_issue_type = value
247
+
248
+ def set_skill_body(self, value: str) -> None:
249
+ self.skill_body = value
250
+
251
+ def set_skill_extra(self, value: str) -> None:
252
+ self.skill_extra = value
253
+
254
+ def set_skill_extra_enabled(self, value: bool) -> None:
255
+ self.skill_extra_enabled = value
256
+
257
+ def load_skills(self) -> None:
258
+ self.skills = [SkillSummary(**skill)
259
+ for skill in SERVICE.list_skills()]
260
+
261
+ def create_skill(self) -> Any:
262
+ saved, pushed, message, slug = SERVICE.create_skill(
263
+ self.new_skill_name)
264
+ if saved:
265
+ self.new_skill_name = ""
266
+ self.load_skills()
267
+ self.edit_skill(slug)
268
+ return _save_toast(saved, pushed, message)
269
+
270
+ def edit_skill(self, slug: str) -> None:
271
+ skill = SERVICE.load_skill(slug)
272
+ self.editing_agents = False
273
+ self.selected_skill = skill["slug"]
274
+ self.skill_name = skill["name"]
275
+ self.skill_description = skill["description"]
276
+ self.skill_model = skill["model"]
277
+ self.model_query = ""
278
+ self.skill_type = skill["type"]
279
+ self.skill_cron = skill["cron"]
280
+ self.skill_email = skill["email"]
281
+ self.skill_sqs = skill["sqs"]
282
+ self.skill_issue_status = skill["issue_status"]
283
+ self.skill_issue_type = skill["issue_type"] or "story"
284
+ self.skill_body = skill["body"]
285
+ self.skill_extra = skill["extra"]
286
+ self.skill_extra_enabled = bool(skill["extra"])
287
+
288
+ def close_skill(self) -> None:
289
+ self.selected_skill = ""
290
+
291
+ def save_skill(self) -> Any:
292
+ saved, pushed, message, slug = SERVICE.save_skill({
293
+ "slug": self.selected_skill,
294
+ "name": self.skill_name,
295
+ "description": self.skill_description,
296
+ "model": self.skill_model,
297
+ "type": self.skill_type,
298
+ "cron": self.skill_cron,
299
+ "email": self.skill_email,
300
+ "sqs": self.skill_sqs,
301
+ "issue_status": self.skill_issue_status,
302
+ "issue_type": self.skill_issue_type,
303
+ "body": self.skill_body,
304
+ # Unchecking the box drops the fields from the frontmatter, while
305
+ # the text stays around in case the box goes back on.
306
+ "extra": self.skill_extra if self.skill_extra_enabled else "",
307
+ })
308
+ if saved:
309
+ self.selected_skill = slug
310
+ self.load_skills()
311
+ return _save_toast(saved, pushed, message)
312
+
313
+ def delete_skill(self) -> Any:
314
+ deleted, pushed, message = SERVICE.delete_skill(self.selected_skill)
315
+ if deleted:
316
+ self.selected_skill = ""
317
+ self.load_skills()
318
+ return _save_toast(deleted, pushed, message)
319
+
320
+ def force_run_skill(self) -> Any:
321
+ SERVICE.force_run_skill(self.selected_skill)
322
+ return rx.toast.success("Queued to run on the next trigger tick")
323
+
324
+ def edit_agents(self) -> None:
325
+ self.selected_skill = ""
326
+ self.agents_content = SERVICE.load_agents()
327
+ self.editing_agents = True
328
+
329
+ def set_agents_content(self, value: str) -> None:
330
+ self.agents_content = value
331
+
332
+ def close_agents(self) -> None:
333
+ self.editing_agents = False
334
+
335
+ def save_agents(self) -> Any:
336
+ return _save_toast(*SERVICE.save_agents(self.agents_content))
337
+
338
+ def load_memories(self) -> None:
339
+ self.memories = [MemoryEntry(**entry)
340
+ for entry in SERVICE.list_memories()]
341
+
342
+ def edit_memory(self, filename: str) -> None:
343
+ self.selected_memory = filename
344
+ self.memory_content = SERVICE.load_memory(filename)
345
+
346
+ def set_memory_content(self, value: str) -> None:
347
+ self.memory_content = value
348
+
349
+ def close_memory(self) -> None:
350
+ self.selected_memory = ""
351
+
352
+ def save_memory(self) -> Any:
353
+ saved, pushed, message = SERVICE.save_memory(
354
+ self.selected_memory, self.memory_content)
355
+ if saved:
356
+ self.load_memories()
357
+ return _save_toast(saved, pushed, message)
358
+
359
+ def delete_memory(self, filename: str, raw: str) -> Any:
360
+ deleted, pushed, message = SERVICE.delete_memory(filename, raw)
361
+ if filename == self.selected_memory:
362
+ self.selected_memory = ""
363
+ self.load_memories()
364
+ return _save_toast(deleted, pushed, message)
365
+
366
+ def _refresh_dashboard(self) -> None:
367
+ dashboard = SERVICE.dashboard()
368
+ self.active_jobs = [
369
+ ActiveJob(
370
+ message=(job.get("message") or "(no prompt)")[:140],
371
+ elapsed_label=job["elapsed_label"],
372
+ viewer_url=(SERVICE.session_viewer.format(session_id=job["session_id"])
373
+ if SERVICE.session_viewer and job.get("session_id") else ""),
374
+ )
375
+ for job in dashboard["active"]
376
+ ]
377
+ self.total_runs = dashboard["counts"]["total"]
378
+ self.last_24h_runs = dashboard["counts"]["last_24h"]
379
+ self.hourly_runs = dashboard["hourly"]
380
+
381
+ @rx.event(background=True)
382
+ async def poll_dashboard(self) -> None:
383
+ async with self:
384
+ if self.dashboard_polling:
385
+ return
386
+ self.dashboard_polling = True
387
+ while True:
388
+ async with self:
389
+ self._refresh_dashboard()
390
+ await asyncio.sleep(1)
391
+
392
+ def _fetch_runs_page(self, offset: int) -> list[RunRecord]:
393
+ """One page of runs. Reads one row past the page to learn whether more exist."""
394
+ rows = SERVICE.recent_runs(RUNS_PAGE_SIZE + 1, offset)
395
+ self.runs_has_more = len(rows) > RUNS_PAGE_SIZE
396
+ records = []
397
+ for run in rows[:RUNS_PAGE_SIZE]:
398
+ message = (run.get("message") or "").strip()
399
+ preview = message.splitlines()[0] if message else "No message"
400
+ records.append(RunRecord(
401
+ skill_name=run["skill_name"],
402
+ trigger_type=run["trigger_type"],
403
+ status=run["status"],
404
+ error=run.get("error") or "",
405
+ started_at=run["started_at"],
406
+ message=message,
407
+ preview=preview[:120] + ("..." if len(preview) > 120 else ""),
408
+ viewer_url=(SERVICE.session_viewer.format(session_id=run["session_id"])
409
+ if SERVICE.session_viewer and run.get("session_id") else ""),
410
+ ))
411
+ return records
412
+
413
+ def load_runs(self) -> None:
414
+ """Load (or reload) the first page. Runs on every visit to /runs."""
415
+ self.runs_loading = False
416
+ self.runs = self._fetch_runs_page(0)
417
+
418
+ def load_more_runs(self) -> None:
419
+ if self.runs_loading or not self.runs_has_more:
420
+ return
421
+ self.runs_loading = True
422
+ try:
423
+ self.runs = self.runs + self._fetch_runs_page(len(self.runs))
424
+ finally:
425
+ self.runs_loading = False
426
+
427
+ @rx.event(background=True)
428
+ async def load_workflow(self, force: bool = False) -> None:
429
+ async with self:
430
+ if self.workflow_loading:
431
+ return
432
+ self.workflow_loading = True
433
+ self.workflow_error = ""
434
+ self.edge_menu_skills = []
435
+ try:
436
+ workflow = await asyncio.to_thread(
437
+ SERVICE.generate_workflow, force)
438
+ except Exception as error:
439
+ async with self:
440
+ self.workflow_error = str(error)
441
+ self.story_workflow_nodes = []
442
+ self.story_workflow_edges = []
443
+ self.story_workflow_warnings = []
444
+ self.task_workflow_nodes = []
445
+ self.task_workflow_edges = []
446
+ self.task_workflow_warnings = []
447
+ self.workflow_loading = False
448
+ return
449
+ async with self:
450
+ self.story_workflow_nodes = workflow["story"]["nodes"]
451
+ self.story_workflow_edges = workflow["story"]["edges"]
452
+ self.story_workflow_warnings = workflow["story"]["warnings"]
453
+ self.task_workflow_nodes = workflow["task"]["nodes"]
454
+ self.task_workflow_edges = workflow["task"]["edges"]
455
+ self.task_workflow_warnings = workflow["task"]["warnings"]
456
+ self.workflow_loading = False
457
+
458
+ def open_edge_menu(self, skills: list[str], x: float, y: float) -> None:
459
+ self.edge_menu_skills = skills
460
+ self.edge_menu_left = f"{round(x)}px"
461
+ self.edge_menu_top = f"{round(y)}px"
462
+
463
+ def close_edge_menu(self) -> None:
464
+ self.edge_menu_skills = []
465
+
466
+ def edit_workflow_skill(self, label: str) -> Any:
467
+ self.edge_menu_skills = []
468
+ slug = SERVICE.resolve_skill_slug(label)
469
+ if not slug:
470
+ return rx.toast.error(f"No skill found for transition '{label}'")
471
+ self.load_skills()
472
+ self.edit_skill(slug)
473
+ return rx.redirect("/skills")
474
+
475
+ def load_settings(self) -> Any:
476
+ settings = SERVICE.load_settings()
477
+ self.tasks_provider = settings.tasks_provider.value
478
+ self.coding_agent = settings.coding_agent.value
479
+ self.max_parallel_agents = str(settings.max_parallel_agents)
480
+ jira = settings.credentials.get("jira", {})
481
+ azure = settings.credentials.get("azure_devops", {})
482
+ self.jira_base_url = jira.get("base_url", "")
483
+ self.jira_account_email = jira.get("account_email", "")
484
+ self.jira_api_token = jira.get("api_token", "")
485
+ self.jira_project = jira.get("project", "")
486
+ self.azure_organization_url = azure.get("organization_url", "")
487
+ self.azure_project = azure.get("project", "")
488
+ self.azure_tenant_id = azure.get("tenant_id", "")
489
+ self.azure_client_id = azure.get("client_id", "")
490
+ self.azure_client_secret = azure.get("client_secret", "")
491
+ self.load_azure_connection()
492
+ return self._azure_callback_toast()
493
+
494
+ def load_azure_connection(self) -> None:
495
+ connection = SERVICE.azure_connection()
496
+ self.azure_connected = connection["connected"]
497
+ self.azure_account = connection["account"]
498
+ self.azure_expires_label = connection["expires_label"]
499
+ self.azure_redirect_uri = SERVICE.azure_redirect_uri()
500
+
501
+ def _azure_callback_toast(self) -> Any:
502
+ """Surface the OAuth outcome the callback route passed back in the URL."""
503
+ params = self.router.url.query_parameters
504
+ outcome = params.get("azure", "")
505
+ if not outcome:
506
+ return None
507
+ message = params.get("message", "")
508
+ if outcome == "connected":
509
+ return rx.toast.success(message or "Connected to Azure DevOps")
510
+ return rx.toast.error(message or "Could not connect to Azure DevOps")
511
+
512
+ def set_tasks_provider(self, value: str) -> None:
513
+ self.tasks_provider = value
514
+
515
+ def set_coding_agent(self, value: str) -> None:
516
+ self.coding_agent = value
517
+
518
+ def set_max_parallel_agents(self, value: str) -> None:
519
+ self.max_parallel_agents = value
520
+
521
+ def set_jira_base_url(self, value: str) -> None:
522
+ self.jira_base_url = value
523
+
524
+ def set_jira_account_email(self, value: str) -> None:
525
+ self.jira_account_email = value
526
+
527
+ def set_jira_api_token(self, value: str) -> None:
528
+ self.jira_api_token = value
529
+
530
+ def set_jira_project(self, value: str) -> None:
531
+ self.jira_project = value
532
+
533
+ def set_azure_organization_url(self, value: str) -> None:
534
+ self.azure_organization_url = value
535
+
536
+ def set_azure_project(self, value: str) -> None:
537
+ self.azure_project = value
538
+
539
+ def set_azure_tenant_id(self, value: str) -> None:
540
+ self.azure_tenant_id = value
541
+
542
+ def set_azure_client_id(self, value: str) -> None:
543
+ self.azure_client_id = value
544
+
545
+ def set_azure_client_secret(self, value: str) -> None:
546
+ self.azure_client_secret = value
547
+
548
+ @rx.var
549
+ def azure_can_connect(self) -> bool:
550
+ """Everything the authorization request and the later queries need."""
551
+ return all(value.strip() for value in (
552
+ self.azure_organization_url, self.azure_project,
553
+ self.azure_client_id, self.azure_client_secret))
554
+
555
+ def connect_azure_devops(self) -> Any:
556
+ """Save the app registration, then hand the browser to Entra ID for consent.
557
+
558
+ Saving first is what makes the callback work: it arrives as its own HTTP
559
+ request and reads the client secret back off disk to exchange the code.
560
+ """
561
+ if not self.azure_can_connect:
562
+ return rx.toast.error(
563
+ "Fill in organization URL, project, client ID and client secret first.")
564
+ error = self._persist_settings()
565
+ if error:
566
+ return rx.toast.error(error)
567
+ started, result = SERVICE.start_azure_authorization()
568
+ if not started:
569
+ return rx.toast.error(result)
570
+ # Same tab, so the callback lands back on /settings once consent is done.
571
+ return rx.redirect(result)
572
+
573
+ def disconnect_azure_devops(self) -> Any:
574
+ SERVICE.disconnect_azure()
575
+ self.load_azure_connection()
576
+ return rx.toast.success("Disconnected from Azure DevOps")
577
+
578
+ def _persist_settings(self) -> str:
579
+ """Write the settings to disk. Returns an error message, or '' when saved."""
580
+ try:
581
+ parallel_agents = int(self.max_parallel_agents)
582
+ except ValueError:
583
+ return "Max parallel tasks must be a number"
584
+ credentials = (
585
+ {
586
+ "base_url": self.jira_base_url,
587
+ "account_email": self.jira_account_email,
588
+ "api_token": self.jira_api_token,
589
+ "project": self.jira_project,
590
+ }
591
+ if self.tasks_provider == "jira"
592
+ else {
593
+ "organization_url": self.azure_organization_url,
594
+ "project": self.azure_project,
595
+ "tenant_id": self.azure_tenant_id,
596
+ "client_id": self.azure_client_id,
597
+ "client_secret": self.azure_client_secret,
598
+ }
599
+ )
600
+ SERVICE.save_settings(
601
+ self.tasks_provider,
602
+ self.coding_agent,
603
+ parallel_agents,
604
+ credentials,
605
+ )
606
+ return ""
607
+
608
+ def save_settings(self) -> Any:
609
+ error = self._persist_settings()
610
+ return rx.toast.error(error) if error else rx.toast.success("Settings saved")
611
+
612
+
613
+ ACCENT = "var(--codee-accent)"
614
+ BORDER = "1px solid var(--codee-border)"
615
+ MUTED = "var(--codee-muted)"
616
+ SURFACE = "var(--codee-surface)"
617
+ PAGE_BACKGROUND = "var(--codee-page-background)"
618
+ NAV_BACKGROUND = "var(--codee-nav-background)"
619
+ TEXT = "var(--codee-text)"
620
+ HOVER = "var(--codee-hover)"
621
+ ACTIVE = "var(--codee-active)"
622
+ GRID = "var(--codee-grid)"
623
+ SUBTLE_ICON = "var(--codee-subtle-icon)"
624
+ RUNNING_BACKGROUND = "var(--codee-running-background)"
625
+ RUNNING_GLOW = "var(--codee-running-glow)"
626
+ MONO = "IBM Plex Mono, monospace"
627
+ LOGO = "👨🏻‍💻"
628
+ # Inline SVG carrying the logo emoji, so the favicon needs no binary asset.
629
+ FAVICON = (
630
+ "data:image/svg+xml;base64,"
631
+ "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMD"
632
+ "AgMTAwIj48dGV4dCB5PSIuOWVtIiBmb250LXNpemU9IjkwIj7wn5Go8J+Pu+KAjfCfkrs8L3Rl"
633
+ "eHQ+PC9zdmc+"
634
+ )
635
+
636
+
637
+ def nav_link(label: str, icon: str, href: str) -> rx.Component:
638
+ is_active = AdminState.active_route == href
639
+ return rx.link(
640
+ rx.hstack(rx.icon(icon, size=17), rx.text(
641
+ label), spacing="3", align="center"),
642
+ href=href,
643
+ aria_current=rx.cond(is_active, "page", ""),
644
+ color=rx.cond(is_active, ACCENT, TEXT),
645
+ background=rx.cond(is_active, ACTIVE, "transparent"),
646
+ font_weight=rx.cond(is_active, "600", "500"),
647
+ box_shadow=rx.cond(
648
+ is_active, f"inset 3px 0 0 0 {ACCENT}", "inset 0 0 0 0 transparent"),
649
+ padding="0.6rem 0.75rem",
650
+ border_radius="6px",
651
+ _hover={"background": rx.cond(is_active, ACTIVE, HOVER),
652
+ "color": ACCENT},
653
+ text_decoration="none",
654
+ width=rx.breakpoints(initial="auto", lg="100%"),
655
+ )
656
+
657
+
658
+ def shell(content: rx.Component) -> rx.Component:
659
+ return rx.box(
660
+ rx.grid(
661
+ rx.box(
662
+ rx.vstack(
663
+ rx.hstack(
664
+ rx.box(LOGO, color="white", background=ACCENT, width="2rem",
665
+ height="2rem", display="grid", place_items="center",
666
+ border_radius="6px", font_weight="700",
667
+ font_size="1.1rem", line_height="1"),
668
+ rx.text("Codee", font_size="1.1rem",
669
+ font_weight="700"),
670
+ rx.spacer(),
671
+ rx.color_mode.button(
672
+ position="static",
673
+ variant="soft",
674
+ aria_label="Toggle color mode",
675
+ ),
676
+ spacing="3",
677
+ align="center",
678
+ width="100%",
679
+ ),
680
+ rx.flex(
681
+ nav_link("Dashboard", "layout-dashboard", "/"),
682
+ nav_link("Skills", "blocks", "/skills"),
683
+ nav_link("Workflow", "git-branch", "/workflow"),
684
+ nav_link("Memory", "notebook-text", "/memory"),
685
+ nav_link("Runs", "history", "/runs"),
686
+ rx.cond(
687
+ AdminState.coding_agent == "claude_code",
688
+ nav_link("Sessions", "key-round", "/sessions"),
689
+ ),
690
+ nav_link("Settings", "settings", "/settings"),
691
+ direction=rx.breakpoints(initial="row", lg="column"),
692
+ wrap="wrap",
693
+ gap="0.2rem",
694
+ width="100%",
695
+ ),
696
+ spacing="6",
697
+ align="start",
698
+ width="100%",
699
+ ),
700
+ padding=rx.breakpoints(initial="1rem", lg="1.5rem"),
701
+ border_right=rx.breakpoints(initial="none", lg=BORDER),
702
+ border_bottom=rx.breakpoints(initial=BORDER, lg="none"),
703
+ background=NAV_BACKGROUND,
704
+ min_height=rx.breakpoints(initial="auto", lg="100vh"),
705
+ ),
706
+ rx.box(content, padding=rx.breakpoints(initial="1.25rem", md="2rem", xl="3rem"),
707
+ min_width="0", max_width="1440px", width="100%"),
708
+ columns=rx.breakpoints(initial="1fr", lg="230px minmax(0, 1fr)"),
709
+ min_height="100vh",
710
+ ),
711
+ background=PAGE_BACKGROUND,
712
+ color=TEXT,
713
+ font_family="IBM Plex Sans, sans-serif",
714
+ style={
715
+ "--codee-accent": rx.color_mode_cond("#167d5a", "#4abd85"),
716
+ "--codee-border": rx.color_mode_cond("#dfe4df", "#354039"),
717
+ "--codee-muted": rx.color_mode_cond("#68716b", "#a1ada5"),
718
+ "--codee-surface": rx.color_mode_cond("#ffffff", "#18201c"),
719
+ "--codee-page-background": rx.color_mode_cond("#f3f5f3", "#101512"),
720
+ "--codee-nav-background": rx.color_mode_cond("#f7f9f7", "#141b17"),
721
+ "--codee-text": rx.color_mode_cond("#202721", "#edf3ef"),
722
+ "--codee-hover": rx.color_mode_cond("#edf5f0", "#213029"),
723
+ "--codee-active": rx.color_mode_cond("#e2efe9", "#23372e"),
724
+ "--codee-grid": rx.color_mode_cond("#e4e9e5", "#2e3932"),
725
+ "--codee-subtle-icon": rx.color_mode_cond("#91a097", "#718078"),
726
+ "--codee-warning-background": rx.color_mode_cond("#fffbeb", "#332a13"),
727
+ "--codee-warning-border": rx.color_mode_cond("#d97706", "#f59e0b"),
728
+ # Tint reserved for in-flight work, so a live run reads at a glance.
729
+ "--codee-running-background": rx.color_mode_cond("#effaf4", "#15271f"),
730
+ "--codee-running-glow": rx.color_mode_cond(
731
+ "rgba(22, 125, 90, 0.16)", "rgba(74, 189, 133, 0.18)"),
732
+ "color_scheme": "light dark",
733
+ },
734
+ )
735
+
736
+
737
+ def page_header(title: str, description: str) -> rx.Component:
738
+ return rx.vstack(
739
+ rx.heading(title, size="7", letter_spacing="0"),
740
+ rx.text(description, color=MUTED, font_size="0.95rem"),
741
+ spacing="1",
742
+ align="start",
743
+ margin_bottom="2rem",
744
+ )
745
+
746
+
747
+ def empty_state(icon: str, text: str) -> rx.Component:
748
+ return rx.center(
749
+ rx.vstack(rx.icon(icon, size=28, color=SUBTLE_ICON), rx.text(text, color=MUTED),
750
+ spacing="3", align="center"),
751
+ border="1px dashed var(--codee-border)",
752
+ min_height="10rem",
753
+ width="100%",
754
+ )
755
+
756
+
757
+ def live_dot(size: str = "0.6rem") -> rx.Component:
758
+ """Accent dot with an expanding halo: the "this is live right now" marker."""
759
+ return rx.box(
760
+ rx.box(position="absolute", inset="0", border_radius="50%", background=ACCENT,
761
+ animation="codee-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite"),
762
+ rx.box(position="absolute", inset="0", border_radius="50%", background=ACCENT),
763
+ class_name="codee-live-dot",
764
+ position="relative",
765
+ width=size,
766
+ height=size,
767
+ flex_shrink="0",
768
+ )
769
+
770
+
771
+ def elapsed_pill(label: rx.Var | str) -> rx.Component:
772
+ return rx.hstack(
773
+ rx.icon("timer", size=14, color=ACCENT),
774
+ rx.text(label, font_family=MONO, font_size="0.85rem", font_weight="500"),
775
+ spacing="2",
776
+ align="center",
777
+ flex_shrink="0",
778
+ padding="0.2rem 0.6rem",
779
+ background=SURFACE,
780
+ border=BORDER,
781
+ border_radius="999px",
782
+ )
783
+
784
+
785
+ def active_job_row(job: ActiveJob) -> rx.Component:
786
+ return rx.flex(
787
+ live_dot(),
788
+ rx.text(job.message, font_weight="600", font_family=MONO, font_size="0.9rem",
789
+ flex="1", min_width="0", overflow="hidden", text_overflow="ellipsis",
790
+ white_space="nowrap", custom_attrs={"title": job.message}),
791
+ elapsed_pill(job.elapsed_label),
792
+ rx.cond(
793
+ job.viewer_url != "",
794
+ rx.link(rx.icon("external-link", size=16), href=job.viewer_url, is_external=True,
795
+ aria_label="View session", color=ACCENT, display="flex",
796
+ align_items="center"),
797
+ ),
798
+ gap="0.85rem",
799
+ align="center",
800
+ padding="0.85rem 1rem",
801
+ background=RUNNING_BACKGROUND,
802
+ border=BORDER,
803
+ border_left=f"3px solid {ACCENT}",
804
+ border_radius="4px",
805
+ width="100%",
806
+ )
807
+
808
+
809
+ def running_tile() -> rx.Component:
810
+ """Stat tile that lights up while sessions are in flight."""
811
+ running = AdminState.active_jobs.length()
812
+ is_running = running > 0
813
+ return rx.box(
814
+ rx.hstack(rx.text("Running now", color=MUTED),
815
+ rx.cond(is_running, live_dot("0.5rem")),
816
+ spacing="2", align="center"),
817
+ rx.heading(running, size="8", color=rx.cond(is_running, ACCENT, TEXT)),
818
+ padding="1.25rem",
819
+ background=rx.cond(is_running, RUNNING_BACKGROUND, SURFACE),
820
+ border=rx.cond(is_running, f"1px solid {ACCENT}", BORDER),
821
+ )
822
+
823
+
824
+ def running_panel() -> rx.Component:
825
+ """Live sessions panel — accent-lit while anything is in flight, quiet when idle."""
826
+ running = AdminState.active_jobs.length()
827
+ is_running = running > 0
828
+ return rx.box(
829
+ rx.hstack(
830
+ rx.cond(is_running, live_dot("0.55rem")),
831
+ rx.heading("Currently running", size="4"),
832
+ rx.cond(
833
+ is_running,
834
+ rx.box(running, color=ACCENT, background=RUNNING_BACKGROUND,
835
+ border=f"1px solid {ACCENT}", border_radius="999px",
836
+ padding="0.05rem 0.55rem", font_size="0.8rem", font_weight="600",
837
+ font_family=MONO, class_name="codee-breathe",
838
+ animation="codee-breathe 2.4s ease-in-out infinite"),
839
+ ),
840
+ spacing="3",
841
+ align="center",
842
+ width="100%",
843
+ margin_bottom="0.9rem",
844
+ ),
845
+ rx.cond(
846
+ is_running,
847
+ rx.vstack(rx.foreach(AdminState.active_jobs, active_job_row),
848
+ spacing="2", width="100%"),
849
+ rx.hstack(rx.icon("moon", size=16, color=SUBTLE_ICON),
850
+ rx.text("No sessions running right now.", color=MUTED),
851
+ spacing="2", align="center"),
852
+ ),
853
+ padding="1.25rem",
854
+ background=SURFACE,
855
+ border=rx.cond(is_running, f"1px solid {ACCENT}", BORDER),
856
+ box_shadow=rx.cond(is_running, f"0 0 0 4px {RUNNING_GLOW}", "none"),
857
+ width="100%",
858
+ )
859
+
860
+
861
+ def dashboard_page() -> rx.Component:
862
+ return shell(rx.vstack(
863
+ page_header(
864
+ "Dashboard", "Run activity and live coding-agent sessions."),
865
+ rx.grid(
866
+ rx.box(rx.text("Total runs", color=MUTED), rx.heading(AdminState.total_runs, size="8"),
867
+ padding="1.25rem", background=SURFACE, border=BORDER),
868
+ rx.box(rx.text("Last 24 hours", color=MUTED), rx.heading(AdminState.last_24h_runs, size="8"),
869
+ padding="1.25rem", background=SURFACE, border=BORDER),
870
+ running_tile(),
871
+ columns=rx.breakpoints(initial="1", sm="2", lg="3"), gap="1rem", width="100%"),
872
+ running_panel(),
873
+ rx.box(
874
+ rx.heading("Last 24 hours by hour",
875
+ size="4", margin_bottom="1rem"),
876
+ rx.recharts.bar_chart(
877
+ rx.recharts.cartesian_grid(
878
+ stroke_dasharray="3 3", stroke=GRID),
879
+ rx.recharts.x_axis(data_key="hour", tick={"fontSize": 11}),
880
+ rx.recharts.y_axis(allow_decimals=False,
881
+ tick={"fontSize": 11}),
882
+ rx.recharts.tooltip(),
883
+ rx.recharts.bar(data_key="runs", fill=ACCENT,
884
+ radius=[3, 3, 0, 0]),
885
+ data=AdminState.hourly_runs,
886
+ width="100%", height=280,
887
+ ),
888
+ padding="1.25rem", background=SURFACE, border=BORDER, width="100%"),
889
+ spacing="5", align="start", width="100%",
890
+ ))
891
+
892
+
893
+ def skill_card(skill: SkillSummary) -> rx.Component:
894
+ return rx.box(
895
+ rx.vstack(
896
+ rx.hstack(rx.heading(skill.name, size="4"), rx.spacer(),
897
+ rx.badge(skill.type, color_scheme="green", variant="soft"), width="100%"),
898
+ rx.text(skill.description, color=MUTED, font_size="0.9rem", min_height="2.7rem",
899
+ overflow="hidden"),
900
+ rx.cond(
901
+ skill.issue_status != "",
902
+ rx.hstack(
903
+ rx.badge(skill.issue_type, color_scheme="green",
904
+ variant="outline"),
905
+ rx.icon("circle-dot", size=14, color=SUBTLE_ICON),
906
+ rx.text(skill.issue_status, color=MUTED,
907
+ font_size="0.82rem"),
908
+ spacing="2",
909
+ align="center",
910
+ width="100%",
911
+ background=HOVER,
912
+ padding="0.55rem 0.65rem",
913
+ border_radius="4px",
914
+ ),
915
+ ),
916
+ rx.button(rx.icon("pencil", size=15), "Edit", variant="soft",
917
+ on_click=AdminState.edit_skill(skill.slug), width="100%"),
918
+ spacing="4", align="start", height="100%", width="100%"),
919
+ padding="1rem", background=SURFACE, border=BORDER, min_height="185px"),
920
+
921
+
922
+ def agents_card() -> rx.Component:
923
+ return rx.box(
924
+ rx.vstack(
925
+ rx.hstack(rx.heading(AGENTS_FILE, size="4"), rx.spacer(),
926
+ rx.badge("always on", color_scheme="gray", variant="soft"), width="100%"),
927
+ rx.text("Plain-text instructions every coding-agent run loads. Cannot be deleted.",
928
+ color=MUTED, font_size="0.9rem", min_height="2.7rem", overflow="hidden"),
929
+ rx.button(rx.icon("pencil", size=15), "Edit", variant="soft",
930
+ on_click=AdminState.edit_agents, width="100%"),
931
+ spacing="4", align="start", height="100%", width="100%"),
932
+ padding="1rem", background=SURFACE, border=BORDER, min_height="185px")
933
+
934
+
935
+ def agents_editor() -> rx.Component:
936
+ return rx.vstack(
937
+ rx.hstack(rx.button(rx.icon("arrow-left", size=16), "Back", variant="ghost",
938
+ on_click=AdminState.close_agents),
939
+ rx.heading(AGENTS_FILE, size="4"), rx.spacer(),
940
+ rx.button(rx.icon("save", size=16), f"Save {AGENTS_FILE}",
941
+ on_click=AdminState.save_agents),
942
+ spacing="3", align="center", width="100%"),
943
+ rx.text("Edited as plain text, without skill frontmatter.",
944
+ color=MUTED, font_size="0.85rem"),
945
+ rx.text_area(value=AdminState.agents_content, on_change=AdminState.set_agents_content,
946
+ width="100%", min_height="32rem", font_family="IBM Plex Mono, monospace"),
947
+ spacing="4", align="start", width="100%")
948
+
949
+
950
+ def field(label: str, control: rx.Component, hint: rx.Component | None = None) -> rx.Component:
951
+ children = [rx.text(label, font_weight="600",
952
+ font_size="0.85rem"), control]
953
+ if hint is not None:
954
+ children.append(hint)
955
+ return rx.vstack(*children, spacing="2", align="start", width="100%")
956
+
957
+
958
+ def model_menu_item(button: rx.Component) -> rx.Component:
959
+ """Make a picker row dismiss the popover while staying clickable end to end.
960
+
961
+ ``rx.popover.close`` wraps any child carrying an ``on_click`` in a Flex of
962
+ its own, and that wrapper hugs its content — so a full-width button inside
963
+ it is still only clickable across the text. The width has to be restated at
964
+ every level to give the row a full-width hit area.
965
+ """
966
+ return rx.popover.close(rx.flex(button, width="100%"), width="100%")
967
+
968
+
969
+ def model_option_row(option: ModelOption) -> rx.Component:
970
+ """One row of the model picker: friendly name left, model code right."""
971
+ return model_menu_item(
972
+ rx.button(
973
+ # No spacer between the two: pinning the code to the right edge ran
974
+ # it under the scroll bar.
975
+ rx.hstack(rx.text(option.name, font_size="0.85rem"),
976
+ rx.code(option.id, font_size="0.72rem",
977
+ color_scheme="gray"),
978
+ align="center", spacing="2", width="100%"),
979
+ variant="ghost", color_scheme="gray", width="100%",
980
+ justify_content="start", padding="0.45rem 0.6rem",
981
+ on_click=AdminState.choose_model(option.id)))
982
+
983
+
984
+ def model_picker() -> rx.Component:
985
+ """Searchable model select that also accepts a model code typed by hand.
986
+
987
+ The agent's own catalog is only a convenience — anything typed here is saved
988
+ verbatim, so a model the agent gained after this list was built still works.
989
+ """
990
+ return field(
991
+ "Model",
992
+ rx.popover.root(
993
+ rx.popover.trigger(
994
+ rx.button(
995
+ rx.hstack(rx.text(AdminState.skill_model_label), rx.spacer(),
996
+ rx.icon("chevrons-up-down", size=14),
997
+ align="center", width="100%"),
998
+ variant="surface", color_scheme="gray", width="100%",
999
+ type="button")),
1000
+ rx.popover.content(
1001
+ rx.vstack(
1002
+ rx.input(placeholder="Search models, or type a model code",
1003
+ value=AdminState.model_query,
1004
+ on_change=AdminState.set_model_query,
1005
+ auto_focus=True, width="100%"),
1006
+ rx.cond(
1007
+ AdminState.custom_model_query != "",
1008
+ model_menu_item(
1009
+ rx.button(
1010
+ rx.hstack(rx.icon("plus", size=14),
1011
+ rx.text("Use "),
1012
+ rx.code(
1013
+ AdminState.custom_model_query),
1014
+ align="center", spacing="2"),
1015
+ variant="soft", width="100%",
1016
+ justify_content="start",
1017
+ padding="0.45rem 0.6rem",
1018
+ on_click=AdminState.choose_model(
1019
+ AdminState.custom_model_query)))),
1020
+ rx.scroll_area(
1021
+ rx.vstack(
1022
+ model_menu_item(
1023
+ rx.button(
1024
+ "Agent default", variant="ghost",
1025
+ color_scheme="gray", width="100%",
1026
+ justify_content="start",
1027
+ padding="0.45rem 0.6rem",
1028
+ on_click=AdminState.choose_model(""))),
1029
+ rx.foreach(AdminState.filtered_models,
1030
+ model_option_row),
1031
+ rx.cond(
1032
+ AdminState.models_loading,
1033
+ rx.text("Loading models from the coding agent…",
1034
+ color=MUTED, font_size="0.8rem",
1035
+ padding="0.5rem")),
1036
+ spacing="1", width="100%"),
1037
+ type="auto", scrollbars="vertical",
1038
+ max_height="15rem", width="100%"),
1039
+ spacing="2", width="100%"),
1040
+ width="24rem"),
1041
+ ),
1042
+ rx.text(
1043
+ rx.cond(AdminState.skill_model == "",
1044
+ "Runs on whatever the coding agent defaults to.",
1045
+ rx.fragment("Saved as ", rx.code(AdminState.skill_model),
1046
+ " in the skill frontmatter.")),
1047
+ color=MUTED, font_size="0.82rem"))
1048
+
1049
+
1050
+ def delete_skill_dialog() -> rx.Component:
1051
+ return rx.alert_dialog.root(
1052
+ rx.alert_dialog.trigger(
1053
+ rx.button(rx.icon("trash-2", size=16), "Delete skill",
1054
+ variant="outline", color_scheme="red")),
1055
+ rx.alert_dialog.content(
1056
+ rx.alert_dialog.title("Delete skill"),
1057
+ rx.alert_dialog.description(
1058
+ "This permanently deletes ", rx.text.strong(
1059
+ AdminState.selected_skill),
1060
+ " and pushes the removal to Git. This cannot be undone."),
1061
+ rx.hstack(
1062
+ rx.alert_dialog.cancel(rx.button("Cancel", variant="soft",
1063
+ color_scheme="gray")),
1064
+ rx.alert_dialog.action(rx.button("Delete skill", color_scheme="red",
1065
+ on_click=AdminState.delete_skill)),
1066
+ spacing="3", justify="end", margin_top="1.25rem", width="100%"),
1067
+ max_width="27rem"),
1068
+ )
1069
+
1070
+
1071
+ def skill_editor() -> rx.Component:
1072
+ return rx.vstack(
1073
+ rx.hstack(rx.button(rx.icon("arrow-left", size=16), "Back", variant="ghost",
1074
+ on_click=AdminState.close_skill), rx.spacer(),
1075
+ delete_skill_dialog(),
1076
+ rx.button(rx.icon("save", size=16), "Save skill",
1077
+ on_click=AdminState.save_skill),
1078
+ spacing="3",
1079
+ width="100%"),
1080
+ rx.grid(
1081
+ field("Name", rx.input(value=AdminState.skill_name,
1082
+ on_change=AdminState.set_skill_name, width="100%")),
1083
+ field("Skill type", rx.select(SKILL_TYPES, value=AdminState.skill_type,
1084
+ on_change=AdminState.set_skill_type, width="100%")),
1085
+ columns=rx.breakpoints(initial="1", md="2"), gap="1rem", width="100%"),
1086
+ field("Description", rx.text_area(value=AdminState.skill_description,
1087
+ on_change=AdminState.set_skill_description,
1088
+ width="100%", min_height="5rem")),
1089
+ model_picker(),
1090
+ rx.cond(AdminState.skill_type == "cron trigger",
1091
+ field("Cron expression", rx.input(value=AdminState.skill_cron,
1092
+ on_change=AdminState.set_skill_cron, width="100%"),
1093
+ rx.hstack(rx.icon("clock-3", size=14), rx.text(AdminState.cron_description),
1094
+ color=MUTED, font_size="0.82rem"))),
1095
+ rx.cond(AdminState.skill_type == "email trigger",
1096
+ field("Email address", rx.input(value=AdminState.skill_email,
1097
+ on_change=AdminState.set_skill_email, width="100%"))),
1098
+ rx.cond(AdminState.skill_type == "aws-sqs trigger",
1099
+ field("AWS SQS queue", rx.input(value=AdminState.skill_sqs,
1100
+ on_change=AdminState.set_skill_sqs, width="100%"))),
1101
+ rx.cond(AdminState.skill_type == "issue trigger",
1102
+ rx.grid(
1103
+ field("Issue type", rx.select(
1104
+ ISSUE_TYPES, value=AdminState.skill_issue_type,
1105
+ on_change=AdminState.set_skill_issue_type, width="100%")),
1106
+ field("Issue statuses", rx.input(value=AdminState.skill_issue_status,
1107
+ on_change=AdminState.set_skill_issue_status,
1108
+ placeholder="Ready, In progress", width="100%")),
1109
+ columns=rx.breakpoints(initial="1", md="2"), gap="1rem", width="100%")),
1110
+ rx.vstack(
1111
+ rx.checkbox("Use other fields in frontmatter (for instance allowed-tools)",
1112
+ checked=AdminState.skill_extra_enabled,
1113
+ on_change=AdminState.set_skill_extra_enabled,
1114
+ size="2"),
1115
+ rx.cond(
1116
+ AdminState.skill_extra_enabled,
1117
+ field("Other frontmatter fields",
1118
+ rx.text_area(value=AdminState.skill_extra,
1119
+ on_change=AdminState.set_skill_extra,
1120
+ placeholder="allowed-tools: Bash",
1121
+ width="100%", min_height="7rem",
1122
+ font_family="IBM Plex Mono, monospace"),
1123
+ rx.text("YAML lines written into the frontmatter as they are. "
1124
+ "Leave out the fields that already have their own control above.",
1125
+ color=MUTED, font_size="0.82rem"))),
1126
+ spacing="3", align="start", width="100%"),
1127
+ field("Skill body", rx.text_area(value=AdminState.skill_body, on_change=AdminState.set_skill_body,
1128
+ width="100%", min_height="25rem",
1129
+ font_family="IBM Plex Mono, monospace")),
1130
+ rx.cond(AdminState.skill_type == "cron trigger",
1131
+ rx.button(rx.icon("play", size=16), "Run on next tick", variant="outline",
1132
+ on_click=AdminState.force_run_skill)),
1133
+ spacing="5", align="start", width="100%",
1134
+ )
1135
+
1136
+
1137
+ def skills_page() -> rx.Component:
1138
+ listing = rx.vstack(
1139
+ rx.flex(rx.input(placeholder="New skill name", value=AdminState.new_skill_name,
1140
+ on_change=AdminState.set_new_skill_name, flex="1"),
1141
+ rx.button(rx.icon("plus", size=16), "Create",
1142
+ on_click=AdminState.create_skill),
1143
+ gap="0.75rem", width="100%"),
1144
+ rx.grid(rx.input(placeholder="Search skills", value=AdminState.skill_query,
1145
+ on_change=AdminState.set_skill_query, width="100%"),
1146
+ rx.select(["All", *SKILL_TYPES], value=AdminState.skill_filter,
1147
+ on_change=AdminState.set_skill_filter, width="100%"),
1148
+ columns=rx.breakpoints(initial="1", md="3fr 1fr"), gap="0.75rem", width="100%"),
1149
+ rx.cond(AdminState.agents_card_visible | (AdminState.filtered_skills.length() > 0),
1150
+ rx.grid(rx.cond(AdminState.agents_card_visible, agents_card()),
1151
+ rx.foreach(AdminState.filtered_skills, skill_card),
1152
+ columns=rx.breakpoints(initial="1", md="2", xl="3"), gap="1rem", width="100%"),
1153
+ empty_state("search-x", "No skills match this view.")),
1154
+ spacing="5", align="start", width="100%")
1155
+ return shell(rx.vstack(page_header("Skills", "Create and configure agent capabilities."),
1156
+ rx.cond(AdminState.editing_agents, agents_editor(),
1157
+ rx.cond(AdminState.selected_skill ==
1158
+ "", listing, skill_editor())),
1159
+ align="start", width="100%"))
1160
+
1161
+
1162
+ def memory_row(entry: MemoryEntry) -> rx.Component:
1163
+ return rx.cond(
1164
+ entry.matched,
1165
+ rx.flex(
1166
+ rx.vstack(rx.text(entry.title, font_weight="600"), rx.text(entry.hook, color=MUTED,
1167
+ font_size="0.85rem"),
1168
+ spacing="1", align="start", flex="1"),
1169
+ rx.text(entry.file, color=MUTED,
1170
+ font_family="IBM Plex Mono, monospace", font_size="0.8rem"),
1171
+ rx.button(rx.icon("pencil", size=15), variant="ghost",
1172
+ on_click=AdminState.edit_memory(entry.file), aria_label="Edit memory"),
1173
+ rx.button(rx.icon("trash-2", size=15), variant="ghost", color_scheme="red",
1174
+ on_click=AdminState.delete_memory(entry.file, entry.raw), aria_label="Delete memory"),
1175
+ gap="0.75rem", align="center", padding="1rem", background=SURFACE,
1176
+ border=BORDER, width="100%"),
1177
+ rx.box(rx.text(entry.raw, font_family="IBM Plex Mono, monospace", font_size="0.85rem"),
1178
+ padding="1rem", border=BORDER, background=SURFACE),
1179
+ )
1180
+
1181
+
1182
+ def memory_editor() -> rx.Component:
1183
+ return rx.vstack(
1184
+ rx.hstack(rx.button(rx.icon("arrow-left", size=16), "Back", variant="ghost",
1185
+ on_click=AdminState.close_memory),
1186
+ rx.heading(AdminState.selected_memory,
1187
+ size="4"), rx.spacer(),
1188
+ rx.button(rx.icon("save", size=16), "Save memory",
1189
+ on_click=AdminState.save_memory),
1190
+ width="100%"),
1191
+ rx.text_area(value=AdminState.memory_content, on_change=AdminState.set_memory_content,
1192
+ width="100%", min_height="32rem", font_family="IBM Plex Mono, monospace"),
1193
+ spacing="4", width="100%"),
1194
+
1195
+
1196
+ def memory_page() -> rx.Component:
1197
+ listing = rx.cond(AdminState.memories.length() > 0,
1198
+ rx.vstack(rx.foreach(AdminState.memories,
1199
+ memory_row), spacing="3", width="100%"),
1200
+ empty_state("notebook-text", "No memories yet."))
1201
+ return shell(rx.vstack(page_header("Memory", "Review and maintain durable project context."),
1202
+ rx.cond(AdminState.selected_memory ==
1203
+ "", listing, memory_editor()),
1204
+ align="start", width="100%"))
1205
+
1206
+
1207
+ def run_row(run: RunRecord) -> rx.Component:
1208
+ return rx.box(
1209
+ rx.flex(
1210
+ rx.vstack(rx.hstack(rx.text(run.skill_name, font_weight="600"),
1211
+ rx.badge(run.status, color_scheme=rx.cond(run.status == "succeeded", "green", "red"))),
1212
+ rx.text(run.started_at, color=MUTED, font_size="0.8rem",
1213
+ font_family="IBM Plex Mono, monospace"),
1214
+ rx.text(run.preview, color=MUTED),
1215
+ rx.cond(run.error != "", rx.text(
1216
+ run.error, color="#b42318", font_size="0.85rem")),
1217
+ spacing="2", align="start", flex="1"),
1218
+ rx.badge(run.trigger_type, variant="outline"),
1219
+ rx.cond(run.viewer_url != "", rx.link(rx.icon("external-link", size=16), href=run.viewer_url,
1220
+ is_external=True, aria_label="View session", color=ACCENT)),
1221
+ gap="1rem", align="start", width="100%"),
1222
+ rx.cond(run.message != "", rx.accordion.root(rx.accordion.item(
1223
+ header="Full message", content=rx.text(run.message, white_space="pre-wrap"), value=run.started_at),
1224
+ collapsible=True, width="100%")),
1225
+ padding="1rem", background=SURFACE, border=BORDER, width="100%")
1226
+
1227
+
1228
+ def runs_page() -> rx.Component:
1229
+ listing = rx.vstack(
1230
+ rx.foreach(AdminState.runs, run_row),
1231
+ rx.cond(AdminState.runs_has_more,
1232
+ rx.button(rx.cond(AdminState.runs_loading, "Loading...",
1233
+ f"Load {RUNS_PAGE_SIZE} more"),
1234
+ variant="soft", width="100%",
1235
+ disabled=AdminState.runs_loading,
1236
+ on_click=AdminState.load_more_runs)),
1237
+ spacing="3", width="100%")
1238
+ return shell(rx.vstack(page_header("Runs", "Recent trigger executions and outcomes."),
1239
+ rx.cond(AdminState.runs.length() > 0, listing,
1240
+ empty_state("history", "No runs recorded yet.")),
1241
+ align="start", width="100%"))
1242
+
1243
+
1244
+ def workflow_warning(message: rx.Var) -> rx.Component:
1245
+ return rx.callout(
1246
+ message,
1247
+ icon="triangle-alert",
1248
+ color_scheme="amber",
1249
+ width="100%",
1250
+ )
1251
+
1252
+
1253
+ def edge_menu_item(skill: rx.Var) -> rx.Component:
1254
+ return rx.button(
1255
+ rx.icon("pencil", size=15),
1256
+ rx.text("Edit skill", font_weight="500"),
1257
+ rx.text(
1258
+ skill,
1259
+ color=MUTED,
1260
+ font_family="IBM Plex Mono, monospace",
1261
+ font_size="0.8rem",
1262
+ ),
1263
+ variant="ghost",
1264
+ justify="start",
1265
+ width="100%",
1266
+ on_click=AdminState.edit_workflow_skill(skill),
1267
+ )
1268
+
1269
+
1270
+ def workflow_edge_menu() -> rx.Component:
1271
+ """Context menu anchored to the last clicked transition arrow."""
1272
+ return rx.cond(
1273
+ AdminState.edge_menu_skills.length() > 0,
1274
+ rx.fragment(
1275
+ rx.box(
1276
+ position="fixed",
1277
+ inset="0",
1278
+ z_index="40",
1279
+ on_click=AdminState.close_edge_menu,
1280
+ ),
1281
+ rx.vstack(
1282
+ rx.foreach(AdminState.edge_menu_skills, edge_menu_item),
1283
+ position="fixed",
1284
+ left=AdminState.edge_menu_left,
1285
+ top=AdminState.edge_menu_top,
1286
+ z_index="41",
1287
+ spacing="1",
1288
+ padding="0.3rem",
1289
+ min_width="12rem",
1290
+ background=SURFACE,
1291
+ border=BORDER,
1292
+ border_radius="6px",
1293
+ box_shadow="0 8px 24px rgba(0, 0, 0, 0.28)",
1294
+ ),
1295
+ ),
1296
+ )
1297
+
1298
+
1299
+ def workflow_section(
1300
+ title: str,
1301
+ nodes: rx.Var,
1302
+ edges: rx.Var,
1303
+ warnings: rx.Var,
1304
+ ) -> rx.Component:
1305
+ return rx.vstack(
1306
+ rx.heading(title, size="5"),
1307
+ rx.cond(
1308
+ nodes.length() > 0,
1309
+ rx.vstack(
1310
+ rx.cond(
1311
+ warnings.length() > 0,
1312
+ rx.vstack(
1313
+ rx.foreach(warnings, workflow_warning),
1314
+ spacing="3",
1315
+ width="100%",
1316
+ ),
1317
+ ),
1318
+ workflow_graph(
1319
+ nodes,
1320
+ edges,
1321
+ on_edge_click=AdminState.open_edge_menu,
1322
+ on_pane_click=AdminState.close_edge_menu,
1323
+ ),
1324
+ spacing="4",
1325
+ width="100%",
1326
+ ),
1327
+ empty_state(
1328
+ "git-branch",
1329
+ f"No {title.lower()} issue-trigger skills found.",
1330
+ ),
1331
+ ),
1332
+ spacing="4",
1333
+ align="start",
1334
+ width="100%",
1335
+ )
1336
+
1337
+
1338
+ def workflow_page() -> rx.Component:
1339
+ return shell(rx.vstack(
1340
+ rx.flex(
1341
+ page_header(
1342
+ "Workflow", "Story and task transitions inferred from issue-trigger skills."),
1343
+ rx.spacer(),
1344
+ rx.button(
1345
+ rx.icon("refresh-cw", size=16),
1346
+ "Regenerate",
1347
+ variant="outline",
1348
+ loading=AdminState.workflow_loading,
1349
+ on_click=AdminState.load_workflow(True),
1350
+ ),
1351
+ align="start",
1352
+ width="100%",
1353
+ ),
1354
+ rx.cond(
1355
+ AdminState.workflow_loading,
1356
+ rx.center(rx.spinner(size="3"), min_height="48rem", width="100%"),
1357
+ rx.cond(
1358
+ AdminState.workflow_error != "",
1359
+ rx.callout(
1360
+ AdminState.workflow_error,
1361
+ icon="triangle-alert",
1362
+ color_scheme="red",
1363
+ width="100%",
1364
+ ),
1365
+ rx.vstack(
1366
+ workflow_section(
1367
+ "Story workflow",
1368
+ AdminState.story_workflow_nodes,
1369
+ AdminState.story_workflow_edges,
1370
+ AdminState.story_workflow_warnings,
1371
+ ),
1372
+ workflow_section(
1373
+ "Task workflow",
1374
+ AdminState.task_workflow_nodes,
1375
+ AdminState.task_workflow_edges,
1376
+ AdminState.task_workflow_warnings,
1377
+ ),
1378
+ spacing="8",
1379
+ width="100%",
1380
+ ),
1381
+ ),
1382
+ ),
1383
+ workflow_edge_menu(),
1384
+ align="start",
1385
+ width="100%",
1386
+ ))
1387
+
1388
+
1389
+ def sessions_page() -> rx.Component:
1390
+ return shell(rx.vstack(
1391
+ page_header(
1392
+ "Sessions", "Open the configured Claude Code session viewer."),
1393
+ rx.cond(AdminState.session_viewer != "",
1394
+ rx.link(rx.button(rx.icon("external-link", size=16), "Open session viewer"),
1395
+ href=AdminState.session_viewer, is_external=True),
1396
+ empty_state("key-round", "Set CODEE_SESSION_VIEWER_URL to enable the session viewer.")),
1397
+ align="start", width="100%"))
1398
+
1399
+
1400
+ def azure_step(number: int, title: str, detail: rx.Component | str) -> rx.Component:
1401
+ return rx.hstack(
1402
+ rx.box(str(number), color="white", background=ACCENT, min_width="1.4rem",
1403
+ height="1.4rem", display="grid", place_items="center",
1404
+ border_radius="50%", font_size="0.75rem", font_weight="700"),
1405
+ rx.vstack(rx.text(title, font_weight="600", font_size="0.9rem"),
1406
+ rx.text(detail, color=MUTED, font_size="0.85rem")
1407
+ if isinstance(detail, str) else detail,
1408
+ spacing="1", align="start", width="100%"),
1409
+ spacing="3", align="start", width="100%")
1410
+
1411
+
1412
+ def azure_redirect_uri_box() -> rx.Component:
1413
+ """The redirect URI to register, copyable — Entra ID matches it character for character."""
1414
+ return rx.hstack(
1415
+ rx.input(value=AdminState.azure_redirect_uri, read_only=True,
1416
+ font_family="IBM Plex Mono, monospace", font_size="0.8rem",
1417
+ width="100%"),
1418
+ rx.button(rx.icon("copy", size=15), variant="outline", type="button",
1419
+ on_click=rx.set_clipboard(AdminState.azure_redirect_uri)),
1420
+ spacing="2", width="100%")
1421
+
1422
+
1423
+ def azure_instructions() -> rx.Component:
1424
+ """Collapsed by default: needed once, when the Entra app is first created."""
1425
+ return rx.accordion.root(
1426
+ rx.accordion.item(
1427
+ header=rx.text("How to create the Azure app registration",
1428
+ font_size="0.9rem", font_weight="600"),
1429
+ content=rx.vstack(
1430
+ azure_step(1, "Register the app",
1431
+ "Azure portal → Microsoft Entra ID → App registrations → "
1432
+ "New registration. Name it Codee. Use the directory that "
1433
+ "backs your Azure DevOps organization."),
1434
+ azure_step(2, "Add a Web redirect URI",
1435
+ rx.vstack(
1436
+ rx.text("Platform Web — not SPA, because Codee exchanges the "
1437
+ "code on the server with the client secret.",
1438
+ color=MUTED, font_size="0.85rem"),
1439
+ azure_redirect_uri_box(),
1440
+ spacing="2", width="100%")),
1441
+ azure_step(3, "Grant the Azure DevOps permission",
1442
+ "API permissions → Add a permission → Azure DevOps → Delegated "
1443
+ "→ user_impersonation → Add. Entra publishes no read-only scope "
1444
+ "for Azure DevOps; Codee only ever issues read calls, and you can "
1445
+ "narrow it further by connecting with an account that has Readers "
1446
+ "access to the project."),
1447
+ azure_step(4, "Create a client secret",
1448
+ "Certificates & secrets → New client secret. Copy the Value "
1449
+ "column immediately — Azure hides it once you leave the page."),
1450
+ azure_step(5, "Copy the app identifiers",
1451
+ "From Overview: Application (client) ID and Directory (tenant) ID."),
1452
+ azure_step(6, "Fill the fields below and connect",
1453
+ "Connecting sends you to Microsoft to sign in. Codee stores the "
1454
+ "resulting access and refresh tokens and renews them on its own."),
1455
+ spacing="4", padding="1rem 0.25rem", width="100%"),
1456
+ ),
1457
+ type="single", collapsible=True, variant="ghost", width="100%")
1458
+
1459
+
1460
+ def azure_connection_status() -> rx.Component:
1461
+ return rx.cond(
1462
+ AdminState.azure_connected,
1463
+ rx.hstack(
1464
+ rx.icon("circle-check", size=17, color=ACCENT),
1465
+ rx.vstack(
1466
+ rx.text(rx.cond(AdminState.azure_account != "",
1467
+ f"Connected as {AdminState.azure_account}",
1468
+ "Connected to Azure DevOps"),
1469
+ font_weight="600", font_size="0.9rem"),
1470
+ rx.text(AdminState.azure_expires_label,
1471
+ color=MUTED, font_size="0.8rem"),
1472
+ spacing="1", align="start"),
1473
+ rx.spacer(),
1474
+ rx.button("Disconnect", variant="outline", color_scheme="red",
1475
+ type="button", on_click=AdminState.disconnect_azure_devops),
1476
+ align="center", width="100%"),
1477
+ rx.callout("Not connected. Fill in the app details, then connect.",
1478
+ icon="info", size="1", color_scheme="gray", width="100%"),
1479
+ )
1480
+
1481
+
1482
+ def azure_fields() -> rx.Component:
1483
+ return rx.vstack(
1484
+ azure_instructions(),
1485
+ field("Organization URL", rx.input(value=AdminState.azure_organization_url,
1486
+ on_change=AdminState.set_azure_organization_url,
1487
+ placeholder="https://dev.azure.com/your-org",
1488
+ width="100%")),
1489
+ field("Project", rx.input(value=AdminState.azure_project,
1490
+ on_change=AdminState.set_azure_project, width="100%")),
1491
+ field("Application (client) ID", rx.input(value=AdminState.azure_client_id,
1492
+ on_change=AdminState.set_azure_client_id,
1493
+ width="100%")),
1494
+ field("Client secret", rx.input(value=AdminState.azure_client_secret,
1495
+ on_change=AdminState.set_azure_client_secret,
1496
+ type="password", width="100%")),
1497
+ field("Directory (tenant) ID", rx.input(value=AdminState.azure_tenant_id,
1498
+ on_change=AdminState.set_azure_tenant_id,
1499
+ width="100%"),
1500
+ hint=rx.text("Optional. Leave empty to sign in against any work or school "
1501
+ "directory you belong to.", color=MUTED, font_size="0.8rem")),
1502
+ azure_connection_status(),
1503
+ rx.button(rx.icon("plug", size=16),
1504
+ rx.cond(AdminState.azure_connected,
1505
+ "Reconnect to Azure DevOps", "Connect to Azure DevOps"),
1506
+ type="button", disabled=~AdminState.azure_can_connect,
1507
+ on_click=AdminState.connect_azure_devops),
1508
+ spacing="4", width="100%")
1509
+
1510
+
1511
+ def settings_page() -> rx.Component:
1512
+ jira_fields = rx.vstack(
1513
+ field("Base URL", rx.input(value=AdminState.jira_base_url,
1514
+ on_change=AdminState.set_jira_base_url, width="100%")),
1515
+ field("Account email", rx.input(value=AdminState.jira_account_email,
1516
+ on_change=AdminState.set_jira_account_email, width="100%")),
1517
+ field("API token", rx.input(value=AdminState.jira_api_token,
1518
+ on_change=AdminState.set_jira_api_token, type="password", width="100%")),
1519
+ field("Project key", rx.input(value=AdminState.jira_project,
1520
+ on_change=AdminState.set_jira_project, width="100%")),
1521
+ spacing="4", width="100%")
1522
+ return shell(rx.vstack(
1523
+ page_header(
1524
+ "Settings", "Choose providers and control executor concurrency."),
1525
+ rx.box(
1526
+ rx.heading("Coding agent", size="4", margin_bottom="1rem"),
1527
+ rx.grid(
1528
+ field("Agent", rx.select(["claude_code", "github_copilot"], value=AdminState.coding_agent,
1529
+ on_change=AdminState.set_coding_agent, width="100%")),
1530
+ field("Max parallel tasks", rx.input(value=AdminState.max_parallel_agents,
1531
+ on_change=AdminState.set_max_parallel_agents,
1532
+ type="number", min=1, width="100%")),
1533
+ columns=rx.breakpoints(initial="1", md="2"), gap="1rem", width="100%"),
1534
+ padding="1.25rem", background=SURFACE, border=BORDER, width="100%"),
1535
+ rx.box(
1536
+ rx.heading("Tasks provider", size="4", margin_bottom="1rem"),
1537
+ field("Provider", rx.select(["jira", "azure_devops"], value=AdminState.tasks_provider,
1538
+ on_change=AdminState.set_tasks_provider, width="100%")),
1539
+ rx.box(rx.cond(AdminState.tasks_provider == "jira",
1540
+ jira_fields, azure_fields()), margin_top="1rem"),
1541
+ padding="1.25rem", background=SURFACE, border=BORDER, width="100%"),
1542
+ rx.button(rx.icon("save", size=16), "Save settings",
1543
+ on_click=AdminState.save_settings),
1544
+ spacing="5", align="start", width="100%"))
1545
+
1546
+
1547
+ app = rx.App(
1548
+ style={
1549
+ "button:not(:disabled), [role='button']:not([aria-disabled='true'])": {
1550
+ "cursor": "pointer",
1551
+ },
1552
+ # Expanding halo behind the live dot on in-flight runs.
1553
+ "@keyframes codee-ping": {
1554
+ "0%": {"transform": "scale(1)", "opacity": "0.55"},
1555
+ "70%": {"transform": "scale(2.6)", "opacity": "0"},
1556
+ "100%": {"transform": "scale(2.6)", "opacity": "0"},
1557
+ },
1558
+ "@keyframes codee-breathe": {
1559
+ "0%, 100%": {"opacity": "1"},
1560
+ "50%": {"opacity": "0.55"},
1561
+ },
1562
+ "@media (prefers-reduced-motion: reduce)": {
1563
+ ".codee-live-dot > *, .codee-breathe": {
1564
+ "animation": "none !important",
1565
+ },
1566
+ },
1567
+ "button:disabled, [role='button'][aria-disabled='true']": {
1568
+ "cursor": "not-allowed",
1569
+ },
1570
+ ".rt-TextFieldRoot": {
1571
+ "background": "var(--codee-surface) !important",
1572
+ "color": "var(--codee-text) !important",
1573
+ "box_shadow": "inset 0 0 0 1px var(--codee-border)",
1574
+ },
1575
+ ".rt-TextFieldInput": {"color": "var(--codee-text) !important"},
1576
+ ".rt-TextAreaRoot": {
1577
+ "background": "var(--codee-surface) !important",
1578
+ "color": "var(--codee-text) !important",
1579
+ },
1580
+ ".rt-SelectTrigger": {
1581
+ "background": "var(--codee-surface) !important",
1582
+ "color": "var(--codee-text) !important",
1583
+ "box_shadow": "inset 0 0 0 1px var(--codee-border)",
1584
+ },
1585
+ ".workflow-node": {
1586
+ "background": "var(--codee-surface)",
1587
+ "border": "1px solid var(--codee-border)",
1588
+ "border_radius": "6px",
1589
+ "color": "var(--codee-text)",
1590
+ "font_family": "IBM Plex Sans, sans-serif",
1591
+ "font_weight": "600",
1592
+ "min_width": "220px",
1593
+ "padding": "0.85rem 1rem",
1594
+ },
1595
+ ".workflow-node--disconnected": {
1596
+ "background": "var(--codee-warning-background)",
1597
+ "border": "2px solid var(--codee-warning-border)",
1598
+ },
1599
+ # No `position` here: React Flow places nodes with `position: absolute`
1600
+ # and a bare `transform`, so overriding it drops the node into normal
1601
+ # flow and shifts every sibling's static position.
1602
+ ".workflow-node--unhandled": {
1603
+ "background": "var(--codee-warning-background)",
1604
+ "border": "2px dashed var(--codee-warning-border)",
1605
+ },
1606
+ ".workflow-node--unhandled::after": {
1607
+ "content": "'No issue trigger skill for this status'",
1608
+ "position": "absolute",
1609
+ "bottom": "calc(100% + 8px)",
1610
+ "left": "50%",
1611
+ "transform": "translateX(-50%)",
1612
+ "background": "var(--codee-surface)",
1613
+ "border": "1px solid var(--codee-warning-border)",
1614
+ "border_radius": "4px",
1615
+ "box_shadow": "0 8px 24px rgba(0, 0, 0, 0.28)",
1616
+ "color": "var(--codee-text)",
1617
+ "font_family": "IBM Plex Sans, sans-serif",
1618
+ "font_size": "0.75rem",
1619
+ "font_weight": "500",
1620
+ "padding": "0.35rem 0.55rem",
1621
+ "white_space": "nowrap",
1622
+ "opacity": "0",
1623
+ "pointer_events": "none",
1624
+ "transition": "opacity 0.12s ease",
1625
+ "z_index": "5",
1626
+ },
1627
+ ".workflow-node--unhandled:hover::after": {"opacity": "1"},
1628
+ ".react-flow__edge.workflow-edge": {
1629
+ "cursor": "pointer",
1630
+ },
1631
+ ".workflow-route-node .react-flow__handle": {
1632
+ "border": "0",
1633
+ "border_radius": "0",
1634
+ "height": "2px",
1635
+ "left": "50%",
1636
+ "min_height": "2px",
1637
+ "min_width": "6px",
1638
+ "right": "auto",
1639
+ "transform": "translate(-50%, -50%)",
1640
+ "width": "6px",
1641
+ },
1642
+ ".workflow-route-node--forward .react-flow__handle": {
1643
+ "background": "#167d5a",
1644
+ },
1645
+ ".workflow-route-node--return .react-flow__handle": {
1646
+ "background": "#d97706",
1647
+ },
1648
+ },
1649
+ stylesheets=[
1650
+ "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
1651
+ ],
1652
+ head_components=[
1653
+ rx.el.link(rel="icon", type="image/svg+xml", href=FAVICON),
1654
+ ],
1655
+ # Serves the OAuth callback route on the same origin as the UI; Reflex
1656
+ # mounts itself underneath, so every other path still reaches the pages.
1657
+ api_transformer=api_app,
1658
+ )
1659
+ app.add_page(dashboard_page, route="/", title="Dashboard | Codee",
1660
+ on_load=AdminState.poll_dashboard)
1661
+ app.add_page(skills_page, route="/skills", title="Skills | Codee",
1662
+ on_load=[AdminState.load_skills, AdminState.load_agent_models])
1663
+ app.add_page(workflow_page, route="/workflow", title="Workflow | Codee",
1664
+ on_load=AdminState.load_workflow)
1665
+ app.add_page(memory_page, route="/memory", title="Memory | Codee",
1666
+ on_load=AdminState.load_memories)
1667
+ app.add_page(runs_page, route="/runs", title="Runs | Codee",
1668
+ on_load=AdminState.load_runs)
1669
+ app.add_page(sessions_page, route="/sessions",
1670
+ title="Sessions | Codee", on_load=AdminState.load_settings)
1671
+ app.add_page(settings_page, route="/settings",
1672
+ title="Settings | Codee", on_load=AdminState.load_settings)