Skip to content

Commit 1ea14c5

Browse files
Feat/copilotkit chat (#859)
* Integrated copilotkit to integrate with chatui. * Cleaned changelog. * build(deps): bump ag-ui-langgraph from 0.0.31 to 0.0.34 Drop the now-redundant set_message_in_progress override on RuntimeContextLangGraphAGUIAgent: upstream already guards the None sentinel merge with or {}, so the override was a no-op (and was triggering Liskov type-check errors because its signature widened MessageInProgress to dict[str, Any]). The get_stream_kwargs override is kept because upstream still dict- merges config/context via .update(), which is incompatible with our frozen RuntimeCtx dataclass passed to LangGraph as context_schema. * test(chat): Rewrite chat API tests for AG-UI endpoint The old tests imported MODEL_ID and hit /models endpoints that were removed when the chat API was rewritten on top of CopilotKit/AG-UI. They failed at collection time. Replace them with coverage of the view's only custom behavior: the 404 raised when X-Repo-ID or X-Ref headers are missing. Everything past that point is third-party (AG-UI, LangGraph, ninja) and not worth exercising in unit tests. * feat(activity): add Activity.thread_id for LangGraph checkpoint persistence * feat(chat): scaffold chat app and shared checkpointer factory * feat(jobs): persist agent-run state via AsyncRedisSaver; accept thread_id * feat(activity): mint thread_id per run and persist on Activity * feat(chat): ChatThread model with per-user ownership * feat(chat): dual-auth endpoint with thread ownership and concurrency check * feat(chat): dashboard views (list, detail, from-activity bridge) * feat(chat): list/detail/message/composer templates and Alpine streaming component * feat(chat): sidebar entry, chat icon, and 'Continue as chat' button on activity detail * style(chat): silence ty diagnostics introduced by new code * refactor(chat): simplify views and reuse open_checkpointer in addressors - Use aget_or_create to resolve TOCTOU races on thread creation (ChatThread.aget_or_create_from_activity and the API endpoint's implicit-create path) - Replace role-normalization ternary with a lookup dict - Use reverse("chat_list") instead of hardcoded URLs in breadcrumbs - Rewrite _extract_first_user_message as a generator expression - Drop dead <template id="message-template"> block and the stale template lookup in chat-stream.js; the inline renderer has always been the only path - Index tool_calls by id (Map) for O(1) lookup instead of Array.find on every arg chunk - Debounce scrollToBottom via requestAnimationFrame to avoid layout thrash under high-rate TEXT_MESSAGE_CHUNK events - Drop unused 'expired' Alpine prop; the template already handles it server-side - Route issue_addressor.py and review_addressor.py through core.checkpointer.open_checkpointer instead of inline AsyncRedisSaver.from_conn_string * fix(chat): surface stream errors and close TOCTOU race on active_run_id - Wrap the AG-UI event generator in try/except: on any exception, log and emit a synthetic RUN_ERROR event so the browser surfaces a real error instead of silently terminating the SSE stream with an empty assistant bubble. - Replace read-then-write with a single conditional aupdate that claims the active_run_id slot only if currently empty — parallel tabs now race to a single winner and the loser gets 409. - Lift slot release out of the handler into a _release_thread helper used by the generator's finally block. - Add tests: happy-path active_run_id cleanup, exception-path cleanup with RUN_ERROR emission, plus unit tests for _extract_first_user_message edge cases (empty, non-string content, whitespace-only). - Client: console.error on network failure + friendlier user message, map known HTTP errors (403/404/409) to actionable copy instead of dumping raw response body, escalate malformed SSE frames from warn to error. * feat(chat): scaffold build_turns helper module * feat(chat): build_turns handles HumanMessage content * feat(chat): build_turns handles AIMessage with string content * feat(chat): build_turns preserves Anthropic block interleaving * feat(chat): build_turns pairs ToolMessage results, drops orphans * feat(chat): view emits turns via build_turns * feat(chat): renderMarkdown wrapper around marked + highlight.js * feat(chat): per-tool signature + expanded-body renderers * feat(chat): diff/grep/bash/todos stylesheet for expanded tool views * feat(chat): component styles for shell, turn, tool cards, composer, rail * feat(chat): right-rail partial with repo, status, todos, files touched * feat(chat): sticky composer with kbd hint, send/stop states, jump pill * feat(chat): rewrite stream controller around turns/segments * feat(chat): rewrite detail template around turns/segments with rail + composer * chore(chat): remove legacy _message and _tool_call_card partials * fix(chat): bash tool renderer key, icon templates, user bubble sizing * fix(chat): right-align user bubble, persistent status bar, unblocked CDN scripts, slimmer rail * fix(chat): drop turn markers, keep streaming signal as left thread line * feat(chat): task renderer + suppress nested frames while task runs * feat(chat): hero repo picker, reasoning segments, file-op rail, run resume - Empty state now mounts a single-repo picker (extracted partial reused by activity); composer fades in once a repo is chosen. - Render reasoning/thinking blocks as collapsible segments, including out-of-band reasoning_content from DeepSeek/Qwen/xAI providers. - Fold the skill tool's injected SKILL.md body into the tool result so reload doesn't show it as a phantom user turn. - Files-touched rail derives ops (added/modified/deleted/renamed) from the sandbox's unified-diff patch, covering bash mutations too. - edit_file diff body uses jsdiff for real hunks; tool signatures parse partial-JSON args so summaries update mid-stream. - New /threads/{id}/status endpoint + activeRunId hydration lets a reloaded page detect when an in-flight run releases its slot. - OpenRouter reasoning kwargs send enabled=true (z.ai GLM ignores effort alone). * style(chat): markdown hierarchy + grep/glob signatures - Flesh out chat-text typography: heading scale with editorial rules under h1/h2, list markers and task-list checkboxes, blockquote side rail, fading hr, kbd keycap, mark/img defaults, and an amber-tinted inline-code chip with a heading-aware override. - Tables get a 100% width, scroll on overflow, header tint, zebra rows, and hover highlight so transcripts read as data, not text. - Pre blocks pick up a thin custom scrollbar so horizontal scroll feels intentional. - grep signature folds an optional glob arg into the scope label; glob expanded body parses the Python-list-repr result so users see one path per line instead of a quoted one-liner. * feat(nav): promote chat to sidebar CTA, move run button to activity * feat(chat): surface pre-existing MR pill on branches with open MRs When a chat opens on a branch that already has an open merge request (created in a prior conversation, by the agent on a different thread, or by a human via the platform UI), the composer pill now appears without waiting for the agent to touch git state. - New RepoClient.get_merge_request_by_branches abstract + GitLab/GitHub implementations; SWE client raises NotImplementedError. - Shared chat.repo_state.aget_existing_mr_payload helper backed by the cached RepositoryConfig.get_config for default-branch lookup. - ChatThreadDetailView falls back to the helper at server-render time. - _emit_repo_state falls back to the helper when LangGraph state has no MR, so the streamed daiv:repo_state event surfaces existing MRs after the first message of a new chat. * feat(chat): publish-phase chips, MR pill, empty-state polish - Render diff_to_metadata subagent structured-response tools (PullRequestMetadata, CommitMetadata) as compact phase chips in the assistant turn and status bar; silence their text/reasoning frames upstream via emit-messages: False so partial JSON never leaks. - Add an MR pill next to the repo chip that hydrates from the server-rendered checkpoint and updates live on daiv:repo_state custom events, with an accent on the branch segment once GitMiddleware commits to a working ref. - Tighten the new-chat empty state: drop the rail column, dock the composer under the hero, stack subtitles in one grid cell to kill the layout jump on repo pick, and soften the composer fade so the top border stays visible. - Suppress asyncio CancelledError tracebacks logged by asgiref when an SSE client disconnects mid-stream; cleanup already runs in the view's finally, the trace is just noise. * feat(chat): step counter on running task, surface sibling tasks Stop suppressing tool-call events whose id differs from the active task when the call itself is another task — sibling parallel-audit subagents were being dropped on the floor, leaving the matching TOOL_CALL_RESULT with nothing to bind to until a page reload rehydrated from checkpoint. For genuinely inner tool calls, tick an innerToolsCount on the parent task segment so the user has some live signal during the long quiet window, surfaced as a small "N steps" chip on the running task card. * refactor(chat): drive MR pill via STATE_SNAPSHOT, split api/views Move from a post-run ``daiv:repo_state`` CustomEvent to AG-UI's native STATE_SNAPSHOT stream for the composer MR pill. ``GitMiddleware`` now publishes ``merge_request`` on the public output schema and seeds it with any pre-existing open MR on the current branch, so the pill reflects reality from the first turn — no end-of-run checkpoint probe. Split ``chat/api/views.py`` into ``streaming.py`` (SSE generator + ``RuntimeContextLangGraphAGUIAgent``), ``threads.py`` (run-slot claim/ release + ref persistence), and ``event_filter.py`` (the subagent event reorder/suppress that previously lived inline). Drop the running-task step counter; the filter now handles nested frames server-side, so the client-side workaround is dead weight. Tool-stream polish: - web_search returns a JSON array; the renderer parses it into per-hit cards and a hit-count badge - web_fetch / gitlab / gh prefix failures with ``error:`` and the gitlab CLI truncation appends a sentinel — both feed result-row badges - new body renderers for web_fetch, web_search, gitlab, gh - activity-stream uses class-map objects so Alpine swaps status variants cleanly instead of letting old classes linger * Review improvements. * fix(chat): Handle parallel tool_calls and misrouted arg deltas ag_ui_langgraph only tracks one current_stream.tool_call_id, so when the LLM emits parallel tool_calls in one AIMessage, sibling args are attributed to the first call's id and concatenated into its segment. Generalize the subagent event filter to: - synthesize TOOL_CALL_START/ARGS/END for any tcid not naturally started (covers the dropped-start case AND parallel siblings), - drop TOOL_CALL_ARGS whose underlying chunk index > 0 (misrouted), - yield STATE_SNAPSHOT before synthesized segments so state-driven UI updates commit first. Also surface read_file errors and "no matches" in grep results in the chat tool renderer. * fix(chat): Defer textarea autosize until after DOM update * feat(chat): Add new chat link to expired conversation banner * docs(mcp): Reflect MR reuse on existing branches in submit_job * feat(titling): Add LLM-generated titles for activities and chat threads Introduces a shared titling pipeline (heuristic + LLM fallback via django-tasks) that backfills concise human-readable titles on Activity and ChatThread rows. Webhook-triggered activities reuse the issue/MR title; scheduled batch runs get a deterministic name; prompt-driven runs and chat threads enqueue an async generation task with LangSmith monitoring metadata. Threads created from an activity reuse its existing title to avoid a duplicate LLM call. * Potential fix for pull request finding 'CodeQL / Double escaping or unescaping' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * test(mcp): Update _FakeActivity mock for title-task enqueue Add a pk attribute to _FakeActivity and patch generate_title_task so the post-create title enqueue path introduced in 166e628 doesn't crash mock-driven submit_job tests. --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 71522a0 commit 1ea14c5

93 files changed

Lines changed: 7597 additions & 509 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

daiv/accounts/context_processors.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010

1111
SECTION_URL_NAMES: dict[str, set[str]] = {
1212
"dashboard": {"dashboard"},
13-
"runs": {"agent_run_new"},
14-
"activity": {"activity_list", "activity_detail", "activity_stream", "activity_download_md"},
13+
"activity": {"activity_list", "activity_detail", "activity_stream", "activity_download_md", "agent_run_new"},
14+
"chat": {"chat_list", "chat_new", "chat_detail"},
1515
"schedules": {
1616
"schedule_list",
1717
"schedule_create",

daiv/accounts/templates/accounts/_sidebar.html

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
</div>
1010

1111
{# Primary action — promoted out of the nav list #}
12-
<a href="{% url 'runs:agent_run_new' %}"
13-
data-testid="nav-start-run-cta"
14-
class="group relative mx-3 mb-5 flex items-center gap-2.5 overflow-hidden rounded-lg bg-gradient-to-br from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold tracking-tight text-white shadow-lg shadow-violet-500/20 ring-1 ring-inset ring-white/15 transition-all duration-200 hover:shadow-violet-500/40 hover:ring-white/25 {% if nav_active_section == 'runs' %}ring-2 ring-white/30 shadow-violet-500/40{% endif %}">
12+
<a href="{% url 'chat_new' %}"
13+
data-testid="nav-chat-cta"
14+
class="group relative mx-3 mb-5 flex items-center gap-2.5 overflow-hidden rounded-lg bg-gradient-to-br from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold tracking-tight text-white shadow-lg shadow-violet-500/20 ring-1 ring-inset ring-white/15 transition-all duration-200 hover:shadow-violet-500/40 hover:ring-white/25">
1515
<span aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-1/2 bg-gradient-to-b from-white/15 to-transparent"></span>
1616
<span class="relative flex h-6 w-6 items-center justify-center rounded-md bg-white/15 ring-1 ring-inset ring-white/20">
17-
{% icon "bolt" "h-3.5 w-3.5" %}
17+
{% icon "squares-plus" "h-3.5 w-3.5" %}
1818
</span>
19-
<span class="relative flex-1">{% translate "Start a run" %}</span>
19+
<span class="relative flex-1">{% translate "New chat" %}</span>
2020
<span aria-hidden="true" class="relative text-white/70 transition-transform duration-200 group-hover:translate-x-0.5">&rarr;</span>
2121
</a>
2222

@@ -42,6 +42,13 @@
4242
{% endif %}
4343
</a>
4444

45+
<a href="{% url 'chat_list' %}"
46+
class="group relative mb-0.5 flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-400 transition-colors hover:text-gray-200 {% nav_active 'chat' %}">
47+
<span class="absolute left-0 top-1.5 bottom-1.5 w-[3px] rounded-r bg-violet-500 {% if nav_active_section != 'chat' %}opacity-0{% endif %}"></span>
48+
{% icon "chat-bubble" "h-4 w-4" %}
49+
<span>{% translate "Chat" %}</span>
50+
</a>
51+
4552
<a href="{% url 'schedule_list' %}"
4653
class="group relative mb-0.5 flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-400 transition-colors hover:text-gray-200 {% nav_active 'schedules' %}">
4754
<span class="absolute left-0 top-1.5 bottom-1.5 w-[3px] rounded-r bg-violet-500 {% if nav_active_section != 'schedules' %}opacity-0{% endif %}"></span>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 6.0.4 on 2026-04-23 23:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [("activity", "0008_activity_batch_id")]
8+
9+
operations = [
10+
migrations.AddField(
11+
model_name="activity",
12+
name="thread_id",
13+
field=models.CharField(
14+
blank=True,
15+
db_index=True,
16+
help_text="LangGraph checkpoint key. Lets chat resume this run.",
17+
max_length=64,
18+
null=True,
19+
unique=True,
20+
verbose_name="thread ID",
21+
),
22+
),
23+
migrations.AddConstraint(
24+
model_name="activity",
25+
constraint=models.CheckConstraint(
26+
condition=models.Q(("thread_id__isnull", True)) | models.Q(("thread_id", ""), _negated=True),
27+
name="activity_thread_id_nonempty",
28+
),
29+
),
30+
]
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Generated by Django 6.0.4 on 2026-04-28 20:57
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [("activity", "0009_activity_thread_id")]
8+
9+
operations = [
10+
migrations.AddField(
11+
model_name="activity",
12+
name="title",
13+
field=models.CharField(blank=True, default="", max_length=120, verbose_name="title"),
14+
)
15+
]

daiv/activity/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ class Activity(models.Model):
8888

8989
status = models.CharField(_("status"), max_length=10, choices=ActivityStatus.choices, default=ActivityStatus.READY)
9090

91+
title = models.CharField(_("title"), max_length=120, blank=True, default="")
92+
9193
batch_id = models.UUIDField(
9294
_("batch ID"),
9395
null=True,
@@ -96,6 +98,16 @@ class Activity(models.Model):
9698
help_text=_("Shared identifier for activities from the same submission."),
9799
)
98100

101+
thread_id = models.CharField(
102+
_("thread ID"),
103+
max_length=64,
104+
null=True,
105+
blank=True,
106+
unique=True,
107+
db_index=True,
108+
help_text=_("LangGraph checkpoint key. Lets chat resume this run."),
109+
)
110+
99111
external_username = models.CharField(
100112
_("external username"),
101113
max_length=255,
@@ -175,6 +187,14 @@ class Meta:
175187
condition=models.Q(external_username__gt=""),
176188
),
177189
]
190+
constraints = [
191+
# ``thread_id`` is unique=True; "" would collide on the second insert
192+
# under Postgres (which treats NULL as not-equal but "" as a real
193+
# value). Forbid the empty-string sentinel so callers must use NULL.
194+
models.CheckConstraint(
195+
condition=models.Q(thread_id__isnull=True) | ~models.Q(thread_id=""), name="activity_thread_id_nonempty"
196+
)
197+
]
178198

179199
def __str__(self) -> str:
180200
return f"{self.get_trigger_type_display()} on {self.repo_id} ({self.status})"

daiv/activity/services.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
from asgiref.sync import async_to_sync
1010
from jobs.tasks import run_job_task
1111

12-
from activity.models import Activity
12+
from activity.models import Activity, TriggerType
13+
from automation.titling.tasks import generate_title_task
14+
15+
_PROMPT_DRIVEN = {TriggerType.API_JOB, TriggerType.MCP_JOB, TriggerType.UI_JOB}
1316

1417
if TYPE_CHECKING:
1518
from notifications.choices import NotifyOn
@@ -96,6 +99,8 @@ def create_activity(
9699
external_username: str = "",
97100
notify_on: NotifyOn | None = None,
98101
batch_id: uuid.UUID | None = None,
102+
thread_id: str | None = None,
103+
title: str = "",
99104
) -> Activity:
100105
"""Create an Activity record linked to a DBTaskResult.
101106
@@ -116,6 +121,8 @@ def create_activity(
116121
external_username=external_username,
117122
notify_on=notify_on,
118123
batch_id=batch_id,
124+
thread_id=thread_id,
125+
title=title[: Activity._meta.get_field("title").max_length],
119126
)
120127

121128

@@ -135,6 +142,8 @@ async def acreate_activity(
135142
external_username: str = "",
136143
notify_on: NotifyOn | None = None,
137144
batch_id: uuid.UUID | None = None,
145+
thread_id: str | None = None,
146+
title: str = "",
138147
) -> Activity:
139148
"""Async variant of create_activity."""
140149
return await Activity.objects.acreate(
@@ -152,6 +161,8 @@ async def acreate_activity(
152161
external_username=external_username,
153162
notify_on=notify_on,
154163
batch_id=batch_id,
164+
thread_id=thread_id,
165+
title=title[: Activity._meta.get_field("title").max_length],
155166
)
156167

157168

@@ -175,16 +186,27 @@ async def asubmit_batch_runs(
175186
_validate(repos)
176187
batch_id = uuid.uuid4()
177188

178-
async def _submit_one(target: RepoTarget) -> Activity | BatchSubmitFailure:
189+
schedule_run_base = 0
190+
if trigger_type == TriggerType.SCHEDULE and scheduled_job is not None:
191+
schedule_run_base = await Activity.objects.filter(scheduled_job=scheduled_job).acount()
192+
193+
async def _submit_one(idx: int, target: RepoTarget) -> Activity | BatchSubmitFailure:
179194
ref_for_task = target.ref or None
195+
thread_id = str(uuid.uuid4())
180196
try:
181-
task = await run_job_task.aenqueue(repo_id=target.repo_id, prompt=prompt, ref=ref_for_task, use_max=use_max)
197+
task = await run_job_task.aenqueue(
198+
repo_id=target.repo_id, prompt=prompt, ref=ref_for_task, use_max=use_max, thread_id=thread_id
199+
)
182200
except Exception as err: # noqa: BLE001
183201
logger.exception("submit_batch_runs: enqueue failed for repo_id=%s batch_id=%s", target.repo_id, batch_id)
184202
return BatchSubmitFailure(repo_id=target.repo_id, ref=target.ref, error=f"{type(err).__name__}: {err}")
185203

204+
activity_title = ""
205+
if trigger_type == TriggerType.SCHEDULE and scheduled_job is not None:
206+
activity_title = f"{scheduled_job.name} · run #{schedule_run_base + idx + 1}"
207+
186208
try:
187-
return await acreate_activity(
209+
activity = await acreate_activity(
188210
trigger_type=trigger_type,
189211
task_result_id=task.id,
190212
repo_id=target.repo_id,
@@ -196,6 +218,8 @@ async def _submit_one(target: RepoTarget) -> Activity | BatchSubmitFailure:
196218
external_username=external_username,
197219
notify_on=notify_on,
198220
batch_id=batch_id,
221+
thread_id=thread_id,
222+
title=activity_title,
199223
)
200224
except Exception:
201225
logger.exception(
@@ -205,9 +229,22 @@ async def _submit_one(target: RepoTarget) -> Activity | BatchSubmitFailure:
205229
)
206230
return BatchSubmitFailure(repo_id=target.repo_id, ref=target.ref, error="ActivityCreationFailed")
207231

232+
if trigger_type in _PROMPT_DRIVEN and prompt:
233+
try:
234+
await generate_title_task.aenqueue(
235+
entity_type="activity",
236+
pk=str(activity.pk),
237+
prompt=prompt,
238+
repo_id=target.repo_id,
239+
ref=target.ref or "",
240+
)
241+
except Exception: # noqa: BLE001
242+
logger.exception("Failed to enqueue title task for activity %s", activity.pk)
243+
return activity
244+
208245
# return_exceptions=True guards against BaseException (CancelledError, etc.) aborting the
209246
# whole batch; _submit_one already catches Exception itself.
210-
outcomes = await asyncio.gather(*[_submit_one(t) for t in repos], return_exceptions=True)
247+
outcomes = await asyncio.gather(*[_submit_one(i, t) for i, t in enumerate(repos)], return_exceptions=True)
211248

212249
activities: list[Activity] = []
213250
failed: list[BatchSubmitFailure] = []

daiv/activity/static/activity/js/activity-stream.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@
22
* Alpine.js components for real-time activity status updates via SSE.
33
*
44
* activityStream (list page) — tracks multiple activities in place:
5-
* dotClass(id, fallback) → "status-dot-{variant}" CSS class
6-
* statusClass(id, fallback) → "status-badge-{variant}" CSS class
5+
* dotClass(id, fallback) → object toggling status-dot-{variant} classes
6+
* statusClass(id, fallback) → object toggling status-badge-{variant} classes
77
* statusLabel(id, fallback) → human-readable label
88
*
9+
* Object class maps (rather than a single string) are required so Alpine
10+
* removes the previously rendered variant class when the status transitions —
11+
* otherwise the static server-rendered class lingers alongside the new one
12+
* and the later CSS rule wins.
13+
*
914
* activityDetail (detail page) — subscribes to one activity and reloads the
1015
* page on any state change so server-rendered fields (started_at, finished_at,
1116
* elapsed counter, duration, timeline dots) reflect the new state.
1217
*/
1318
document.addEventListener("alpine:init", () => {
19+
const VARIANTS = ["success", "failed", "running", "pending"];
20+
1421
function statusVariantFor(status) {
1522
if (status === "SUCCESSFUL") return "success";
1623
if (status === "FAILED") return "failed";
@@ -25,6 +32,10 @@ document.addEventListener("alpine:init", () => {
2532
return "Pending";
2633
}
2734

35+
function variantClassMap(prefix, active) {
36+
return Object.fromEntries(VARIANTS.map((v) => [prefix + v, v === active]));
37+
}
38+
2839
Alpine.data("activityStream", (streamUrl, inFlightIds) => ({
2940
updates: {},
3041
init() {
@@ -42,10 +53,10 @@ document.addEventListener("alpine:init", () => {
4253
source.onerror = () => source.close();
4354
},
4455
statusClass(id, fallback) {
45-
return "status-badge-" + statusVariantFor(this.updates[id]?.status || fallback);
56+
return variantClassMap("status-badge-", statusVariantFor(this.updates[id]?.status || fallback));
4657
},
4758
dotClass(id, fallback) {
48-
return "status-dot-" + statusVariantFor(this.updates[id]?.status || fallback);
59+
return variantClassMap("status-dot-", statusVariantFor(this.updates[id]?.status || fallback));
4960
},
5061
statusLabel(id, fallback) {
5162
const update = this.updates[id];

daiv/activity/static/activity/js/prompt-box.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@ document.addEventListener("alpine:init", () => {
1515
repoPickerUrl = "",
1616
branchPickerTemplate = "",
1717
conflictMessageTemplate = "Repository already in the list: __LABEL__.",
18+
onChangeEvent = "",
1819
}) => ({
1920
repos: (initialRepos || []).map(r => ({ slug: r.repo_id, ref: r.ref || "" })),
2021
useMax: initialUseMax,
2122
maxRepos,
2223
repoPickerUrl,
2324
branchPickerTemplate,
2425
conflictMessageTemplate,
26+
onChangeEvent,
2527

2628
popover: null,
2729
editingIndex: null,
@@ -45,6 +47,15 @@ document.addEventListener("alpine:init", () => {
4547
});
4648
},
4749

50+
_emitChange() {
51+
if (!this.onChangeEvent) return;
52+
window.dispatchEvent(
53+
new CustomEvent(this.onChangeEvent, {
54+
detail: { repos: this.repos.map((r) => ({ repo_id: r.slug, ref: r.ref || "" })) },
55+
}),
56+
);
57+
},
58+
4859
destroy() {
4960
if (this._conflictTimer) clearTimeout(this._conflictTimer);
5061
},
@@ -109,6 +120,7 @@ document.addEventListener("alpine:init", () => {
109120
if (this.editingIndex === null) this.repos.push(entry);
110121
else this.repos.splice(this.editingIndex, 1, entry);
111122
this.closePopover();
123+
this._emitChange();
112124
},
113125

114126
setBranch(ref) {
@@ -122,6 +134,7 @@ document.addEventListener("alpine:init", () => {
122134
}
123135
this.repos[this.editingIndex].ref = ref;
124136
this.closePopover();
137+
this._emitChange();
125138
},
126139

127140
remove(index) {
@@ -134,6 +147,7 @@ document.addEventListener("alpine:init", () => {
134147
if (this.editingIndex === index) this.closePopover();
135148
else if (index < this.editingIndex) this.editingIndex -= 1;
136149
}
150+
this._emitChange();
137151
},
138152

139153
_findConflict(slug, ref, skipIndex) {

0 commit comments

Comments
 (0)