Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions frontend/src/lib/features/collections/CollectionBacklog.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script>
import { onMount } from 'svelte';
import { useEventListener } from 'runed';
import { items } from '../../api/items.js';
import { iterations } from '../../api/milestones.js';
import { t } from '../../stores/i18n.svelte.js';
Expand All @@ -24,6 +23,7 @@
import { sortBacklogIterations } from './backlogIterationOrder.js';
import ItemPicker from '../../pickers/ItemPicker.svelte';
import { backlogStore } from '../../stores/backlogStore.svelte.js';
import { onWorkItemRefresh } from '../../stores/workItemRefresh.js';
import { workspaceDataStore } from '../../stores/workspaceDataStore.svelte.js';
import { collectionPreferences } from '../../stores/collectionPreferences.svelte.js';
import { itemModalStore } from '../../stores/itemModalStore.svelte.js';
Expand Down Expand Up @@ -228,10 +228,10 @@
const styles = useGradientStyles();

// Listen for newly created items
async function handleRefreshWorkItems(event) {
if (!event.detail?.itemId) return;
async function handleRefreshWorkItems(detail) {
if (!detail?.itemId) return;
try {
const newItem = event.detail.item ?? await items.get(event.detail.itemId);
const newItem = detail.item ?? await items.get(detail.itemId);
// Collection QL and sub-filters depend on server state, so verify rather
// than assume membership. Unknown never permits an insert; it reconciles.
const membership = await resolveCreatedItemMembership(newItem, {
Expand All @@ -251,7 +251,7 @@
}
}

useEventListener(() => window, 'refresh-work-items', handleRefreshWorkItems);
$effect(() => onWorkItemRefresh(handleRefreshWorkItems));

onMount(async () => {
if (workspaceId) {
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/lib/features/collections/CollectionBoard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { onMount, untrack } from 'svelte';
import { useEventListener } from 'runed';
import { t } from '../../stores/i18n.svelte.js';
import { onWorkItemRefresh } from '../../stores/workItemRefresh.js';
import { items as itemsApi } from '../../api/items.js';
import { getAssignableUsers } from '../../api/users.js';
import { labels as labelsApi } from '../../api/misc.js';
Expand Down Expand Up @@ -263,10 +264,10 @@
}

// Listen for newly created items
async function handleRefreshWorkItems(event) {
if (!event.detail?.itemId) return;
async function handleRefreshWorkItems(detail) {
if (!detail?.itemId) return;
try {
const newItem = event.detail.item ?? (await itemsApi.get(event.detail.itemId));
const newItem = detail.item ?? (await itemsApi.get(detail.itemId));
const membership = await resolveCreatedItemMembership(newItem, createdItemScope());
if (membership === 'member') {
// A create event can arrive more than once (modal event plus a delta or
Expand All @@ -281,7 +282,7 @@
}
}

useEventListener(() => window, 'refresh-work-items', handleRefreshWorkItems);
$effect(() => onWorkItemRefresh(handleRefreshWorkItems));

// Quick-add functions

Expand Down
24 changes: 13 additions & 11 deletions frontend/src/lib/features/collections/CollectionList.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script>
import { useEventListener } from 'runed';
import { t } from '../../stores/i18n.svelte.js';
import { errorToast } from '../../stores/toasts.svelte.js';
import { onWorkItemRefresh } from '../../stores/workItemRefresh.js';
import {
applyCollectionItem,
collectionStore,
Expand Down Expand Up @@ -98,16 +98,18 @@

let gridMinWidth = $derived(listGridMinWidth(listColumns));

useEventListener(() => window, 'refresh-work-items', (/** @type {CustomEvent} */ event) => {
const item = event.detail?.item;
// Workspace-scoped views can apply the item immediately as a display
// optimization. Collection membership is verified by the store's
// coalesced reconciliation instead of one query per event.
if (item && !collectionId && Number(item.workspace_id) === Number(workspaceId)) {
applyCollectionItem(item);
}
scheduleCollectionReconciliation();
});
$effect(() =>
onWorkItemRefresh((detail) => {
const item = detail?.item;
// Workspace-scoped views can apply the item immediately as a display
// optimization. Collection membership is verified by the store's
// coalesced reconciliation instead of one query per event.
if (item && !collectionId && Number(item.workspace_id) === Number(workspaceId)) {
applyCollectionItem(item);
}
scheduleCollectionReconciliation();
})
);

// Scoped SSE invalidations (WI-1624): any frame runs the existing delta fetch,
// and a reconnect reconciles. Without this the list kept showing items that
Expand Down
29 changes: 18 additions & 11 deletions frontend/src/lib/features/items/ItemDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import { useItemEventStream } from '../../composables/useItemEventStream.svelte.js';
import { notificationActions } from '../../stores/notifications.js';
import { agentRuns } from '../../stores/agentRuns.svelte.js';
import { onWorkItemRefresh } from '../../stores/workItemRefresh.js';
import {
registerContextCommands,
unregisterContextCommands,
Expand Down Expand Up @@ -300,17 +301,23 @@ import NativeSelect from '../../components/NativeSelect.svelte';

useEventListener(() => document, 'keydown', handleItemDetailShortcuts);

// Reload child items when a new child is created against the item currently open.
useEventListener(() => window, 'refresh-work-items', (/** @type {CustomEvent<{parentId?: number}>} */ event) => {
const parentId = event?.detail?.parentId;
if (parentId == null) return;
const currentId = itemDetailStore.item?.id;
if (currentId == null) return;
if (Number(parentId) !== Number(currentId)) return;
itemDetailStore.loadChildItems().catch((err) => {
console.error('Failed to reload child items after child creation:', err);
});
});
// Reload child items when a new child is created against the item currently
// open. The coordinator batches a burst; check every parent it named.
$effect(() =>
onWorkItemRefresh((detail) => {
const currentId = itemDetailStore.item?.id;
if (currentId == null) return;
const parents = detail?.parentIds?.length
? detail.parentIds
: detail?.parentId != null
? [detail.parentId]
: [];
if (!parents.some((parentId) => Number(parentId) === Number(currentId))) return;
itemDetailStore.loadChildItems().catch((err) => {
console.error('Failed to reload child items after child creation:', err);
});
})
);

function handleFocusStatus() {
// Focus the status field by starting edit mode
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/features/items/ItemDetailSidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@
/>
{/snippet}
{#if item?.id}
<ItemSLAPanel itemId={item.id} />
<ItemSLAPanel itemId={item.id} workspaceId={item.workspace_id} />
{/if}
<!-- DETAILS Section Header -->
<div class="flex items-center justify-between mb-2">
Expand Down
19 changes: 13 additions & 6 deletions frontend/src/lib/features/items/ItemSLAPanel.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
<script>
import { useEventListener } from 'runed';
import { sla } from '../../api/sla.js';
import Lozenge from '../../components/Lozenge.svelte';
import Tooltip from '../../components/Tooltip.svelte';
import Gauge from '@lucide/svelte/icons/gauge';
import Clock from '@lucide/svelte/icons/clock';
import { t } from '../../stores/i18n.svelte.js';
import { formatInstant } from '../../utils/dateFormatter.js';
import { onWorkItemRefresh, workItemRefreshTouchesWorkspace } from '../../stores/workItemRefresh.js';
import { getItemSLA } from '../sla/slaState.js';

let { itemId = null } = $props();
let { itemId = null, workspaceId = null } = $props();

// Server-computed remaining/breach state has to be re-read while a timer is
// visible; elapsed time and business-hours transitions never reach the client
Expand All @@ -25,7 +25,7 @@
const requestedItemId = itemId;
loading = true;
try {
const result = (await sla.getItemSLA(requestedItemId)) ?? [];
const result = (await getItemSLA(requestedItemId, workspaceId)) ?? [];
// A newer request or an item switch supersedes this response.
if (token !== requestToken) return;
states = result;
Expand All @@ -51,8 +51,15 @@
});

// Item mutations (status, assignee, comments, etc.) are broadcast globally;
// refresh the derived SLA state when they happen.
useEventListener(() => window, 'refresh-work-items', () => void load());
// refresh the derived SLA state when they happen. Reading through slaState
// means the panel and any SLA badges in the same workspace share one batch,
// and a burst in another workspace is skipped entirely.
$effect(() =>
onWorkItemRefresh((detail) => {
if (!workItemRefreshTouchesWorkspace(detail, workspaceId)) return;
void load();
})
);

function formatDuration(ms) {
if (ms == null) return '';
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/lib/features/sla/SLABadge.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script>
import { useEventListener } from 'runed';
import Lozenge from '../../components/Lozenge.svelte';
import Tooltip from '../../components/Tooltip.svelte';
import { t } from '../../stores/i18n.svelte.js';
import { formatInstant } from '../../utils/dateFormatter.js';
import { onWorkItemRefresh, workItemRefreshTouchesWorkspace } from '../../stores/workItemRefresh.js';
import { getItemSLA, getSLAThresholds, onSLAThresholdsInvalidated } from './slaState.js';

let { itemId = null, workspaceId = null, refreshToken = 0 } = $props();
Expand Down Expand Up @@ -37,7 +37,12 @@
};
});

useEventListener(() => window, 'refresh-work-items', () => void load(itemId, workspaceId, refreshToken));
$effect(() =>
onWorkItemRefresh((detail) => {
if (!workItemRefreshTouchesWorkspace(detail, workspaceId)) return;
void load(itemId, workspaceId, refreshToken);
})
);

// A threshold mutation in this tab must refresh mounted badges immediately;
// failed mutations never emit, and other tabs pick the change up once the
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/pages/Homepage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import Trash2 from '@lucide/svelte/icons/trash-2';
import X from '@lucide/svelte/icons/x';
import { useEventListener } from 'runed';
import { onWorkItemRefresh } from '../stores/workItemRefresh.js';
import { createDashboardWidgetDrag } from '../composables/dashboardWidgetDrag.js';
import { useDashboardCustomization } from '../composables/useDashboardCustomization.svelte.js';
import { confirm } from '../composables/useConfirm.js';
Expand Down Expand Up @@ -112,7 +113,7 @@
}

useEventListener(() => window, 'refresh-workspaces', handleRefresh);
useEventListener(() => window, 'refresh-work-items', handleRefresh);
$effect(() => onWorkItemRefresh(handleRefresh));

// --- Layout / mode handlers ---

Expand Down
9 changes: 6 additions & 3 deletions frontend/src/lib/pages/useMainAppLifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { authStore } from '../stores/auth.svelte.js';
import { capabilitiesStore } from '../stores/capabilities.svelte.js';
import { homepageStore } from '../stores/homepageStore.svelte.js';
import { startNotificationPoller, stopNotificationPoller } from '../stores/notifications.js';
import { onWorkItemRefresh } from '../stores/workItemRefresh.js';
import { workspaceDataStore } from '../stores/workspaceDataStore.svelte.js';
import { workspacesStore } from '../stores/workspaces.svelte.js';
import { initDesktopFocusRefresh } from '../utils/desktopFocusRefresh.svelte.js';
Expand Down Expand Up @@ -124,20 +125,22 @@ export function useMainAppLifecycle({
void workspacesStore.reload();
};
const handleRefreshWorkspaceData = () => void workspaceDataStore.refresh();
const handleRefreshWorkItems = () => homepageStore.invalidateSnapshot();
// The homepage only marks its snapshot stale here; the mounted Homepage
// component refreshes it. One coordinator owns the event so this is not a
// second independent listener.
const unsubscribeRefreshWorkItems = onWorkItemRefresh(() => homepageStore.invalidateSnapshot());

window.addEventListener('show-create-modal', handleShowCreateModal);
window.addEventListener(ADMIN_UI_MUTATION_EVENT, scheduleAdminUIRefresh);
window.addEventListener('refresh-workspaces', handleRefreshWorkspaces);
window.addEventListener('refresh-workspace-data', handleRefreshWorkspaceData);
window.addEventListener('refresh-work-items', handleRefreshWorkItems);

return () => {
window.removeEventListener('show-create-modal', handleShowCreateModal);
window.removeEventListener(ADMIN_UI_MUTATION_EVENT, scheduleAdminUIRefresh);
window.removeEventListener('refresh-workspaces', handleRefreshWorkspaces);
window.removeEventListener('refresh-workspace-data', handleRefreshWorkspaceData);
window.removeEventListener('refresh-work-items', handleRefreshWorkItems);
unsubscribeRefreshWorkItems();
if (adminUIRefreshTimer) clearTimeout(adminUIRefreshTimer);
};
});
Expand Down
Loading
Loading