mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 00:06:14 +00:00
fix(sessions): session bar badge readouts, job persistence, refresh bounce (#10217)
This commit is contained in:
@@ -627,8 +627,26 @@ export class AIChatManager {
|
||||
updateJob = (jobId: string, update: Partial<ChatJob>) => {
|
||||
const idx = this.backgroundJobs.findIndex((j) => j.jobId === jobId)
|
||||
if (idx === -1) return
|
||||
const wasTerminal = !this.isJobNonTerminal(this.backgroundJobs[idx].status)
|
||||
this.backgroundJobs[idx] = { ...this.backgroundJobs[idx], ...update }
|
||||
this.backgroundJobs = [...this.backgroundJobs]
|
||||
// Persist on the transition to terminal: a job that completes inside the
|
||||
// inline wait never hits the detach/poller persist paths, and would
|
||||
// otherwise vanish from the tray on reload.
|
||||
if (!wasTerminal && !this.isJobNonTerminal(this.backgroundJobs[idx].status)) {
|
||||
void this.#persistBackgroundJobs()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark finished jobs as reviewed (their terminal status was shown in the
|
||||
* jobs popover) and persist, so the chip stays relaxed across reloads. */
|
||||
markJobsReviewed = (jobIds: string[]) => {
|
||||
const ids = new Set(jobIds)
|
||||
if (!this.backgroundJobs.some((j) => ids.has(j.jobId) && !j.reviewed)) return
|
||||
this.backgroundJobs = this.backgroundJobs.map((j) =>
|
||||
ids.has(j.jobId) && !j.reviewed ? { ...j, reviewed: true } : j
|
||||
)
|
||||
void this.#persistBackgroundJobs()
|
||||
}
|
||||
|
||||
/** A job left the inline wait — hand it to the background poller. */
|
||||
|
||||
@@ -2539,6 +2539,37 @@ describe('AIChatManager background job completion', () => {
|
||||
expect(manager.pendingJobNotes).toHaveLength(1)
|
||||
expect(manager.pendingJobNotes[0]).toContain('Background job job-1 for "run" succeeded')
|
||||
})
|
||||
|
||||
it('persists on the inline terminal transition and on review', async () => {
|
||||
const manager = new AIChatManager()
|
||||
manager.registerJob({
|
||||
jobId: 'job-1',
|
||||
toolCallId: 'tc-1',
|
||||
kind: 'script',
|
||||
label: 'run',
|
||||
workspace: 'ws'
|
||||
})
|
||||
const saveChat = vi.spyOn(manager.historyManager, 'saveChat').mockResolvedValue(undefined)
|
||||
|
||||
// Inline completion reports through updateJob without ever detaching; the
|
||||
// terminal transition alone must write the tray or the job vanishes on reload.
|
||||
manager.updateJob('job-1', { status: 'running' })
|
||||
expect(saveChat).not.toHaveBeenCalled()
|
||||
manager.updateJob('job-1', { status: 'success' })
|
||||
await vi.waitFor(() => expect(saveChat).toHaveBeenCalledTimes(1))
|
||||
expect(saveChat.mock.calls[0][4]).toEqual([
|
||||
expect.objectContaining({ jobId: 'job-1', status: 'success' })
|
||||
])
|
||||
|
||||
// Reviewing persists the flag; re-reviewing is a no-op (no extra write).
|
||||
manager.markJobsReviewed(['job-1'])
|
||||
await vi.waitFor(() => expect(saveChat).toHaveBeenCalledTimes(2))
|
||||
expect(saveChat.mock.calls[1][4]).toEqual([
|
||||
expect.objectContaining({ jobId: 'job-1', reviewed: true })
|
||||
])
|
||||
manager.markJobsReviewed(['job-1'])
|
||||
expect(saveChat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DOM selector chips scoped by app path', () => {
|
||||
|
||||
@@ -32,8 +32,21 @@
|
||||
const queuedCount = $derived(
|
||||
jobs.filter((j) => j.status === 'queued' || j.status === 'scheduled').length
|
||||
)
|
||||
const failureCount = $derived(jobs.filter((j) => j.status === 'failure').length)
|
||||
const successCount = $derived(jobs.filter((j) => j.status === 'success').length)
|
||||
// A finished job counts as reviewed once its terminal status has been shown in
|
||||
// the open popover; a job that finishes while the popover is closed starts
|
||||
// unreviewed. The flag lives on the job (persisted with it), so review state
|
||||
// survives a reload.
|
||||
$effect(() => {
|
||||
if (!open) return
|
||||
aiChatManager.markJobsReviewed(jobs.filter((j) => isTerminal(j.status)).map((j) => j.jobId))
|
||||
})
|
||||
// Only unreviewed jobs feed the terminal readout: an outcome the user already
|
||||
// saw must not resurface on the chip when a later job finishes.
|
||||
const unreviewed = $derived(jobs.filter((j) => !j.reviewed))
|
||||
const allReviewed = $derived(jobs.length > 0 && unreviewed.length === 0)
|
||||
|
||||
const failureCount = $derived(unreviewed.filter((j) => j.status === 'failure').length)
|
||||
const successCount = $derived(unreviewed.filter((j) => j.status === 'success').length)
|
||||
const liveCount = $derived(jobs.filter((j) => !isTerminal(j.status)).length)
|
||||
const hasLive = $derived(liveCount > 0)
|
||||
|
||||
@@ -70,9 +83,18 @@
|
||||
|
||||
// Aggregate chip readout, priority-ordered so the most action-worthy state
|
||||
// wins the dot: approval > running > queued > failed > succeeded. A live run
|
||||
// takes the dot even if an earlier job failed (failure resurfaces once idle).
|
||||
// takes the dot even if an earlier job failed (an unreviewed failure
|
||||
// resurfaces once idle). Once every finished job has been reviewed in the
|
||||
// popover, the chip relaxes to a neutral executed-count.
|
||||
const segment = $derived.by(
|
||||
(): { dot: string; pulse: boolean; text: string; danger: boolean } => {
|
||||
if (allReviewed)
|
||||
return {
|
||||
dot: 'bg-gray-400',
|
||||
pulse: false,
|
||||
text: `${jobs.length} job${jobs.length === 1 ? '' : 's'} executed`,
|
||||
danger: false
|
||||
}
|
||||
if (approvalCount > 0)
|
||||
return {
|
||||
dot: dotClass('suspended'),
|
||||
@@ -111,8 +133,8 @@
|
||||
text: `${failureCount} failed`,
|
||||
danger: true
|
||||
}
|
||||
// All terminal, none failed: green if anything actually succeeded, else gray
|
||||
// (only canceled jobs left — a cancel isn't a success, so don't show green).
|
||||
// All terminal, nothing unreviewed failed: green if anything unreviewed
|
||||
// succeeded, else gray (only canceled left — a cancel isn't a success).
|
||||
if (successCount > 0)
|
||||
return {
|
||||
dot: dotClass('success'),
|
||||
@@ -123,7 +145,7 @@
|
||||
return {
|
||||
dot: dotClass('canceled'),
|
||||
pulse: false,
|
||||
text: `${jobs.length} canceled`,
|
||||
text: `${unreviewed.length} canceled`,
|
||||
danger: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -899,6 +899,9 @@ export type ChatJob = {
|
||||
detached: boolean
|
||||
/** Notify-only: whether its completion has been surfaced to the model yet. */
|
||||
reported: boolean
|
||||
/** Whether the user saw its terminal status in the jobs popover. Reviewed
|
||||
* outcomes stop driving the segment chip's status readout. Persisted. */
|
||||
reviewed?: boolean
|
||||
/** Trimmed snapshot of the last fetched Job (heavy fields stripped, see
|
||||
* `trimJob`), fed to `<JobStatusIcon>` so the tray badge matches the runs page
|
||||
* exactly. Always written together with `status` from the SAME job so the two
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { badgeCounts, badgeOf, buildDeployItems } from './sessionDeployModel'
|
||||
import { useExistingMaskKeys } from './sessionDeployModel.svelte'
|
||||
import JobsSegment from '$lib/components/copilot/chat/JobsSegment.svelte'
|
||||
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
|
||||
import ArtifactsSegment from '$lib/components/copilot/chat/artifacts/ArtifactsSegment.svelte'
|
||||
|
||||
// Unified session bar: surfaces what the CURRENT chat changed — pending
|
||||
@@ -173,8 +174,13 @@
|
||||
const hasJobs = $derived((runtime?.manager.backgroundJobs.length ?? 0) > 0)
|
||||
const hasArtifacts = $derived((runtime?.manager.artifacts.artifacts.length ?? 0) > 0)
|
||||
|
||||
const editsCount = $derived(dockCounts.draft + dockCounts.deployed)
|
||||
const editsLabel = $derived(`${editsCount} edit${editsCount === 1 ? '' : 's'}`)
|
||||
// Drafts are what still needs action, so the token counts only them while any
|
||||
// are pending; once none are left it turns green and counts the deployed.
|
||||
const editsLabel = $derived(
|
||||
dockCounts.draft > 0
|
||||
? `${dockCounts.draft} draft${dockCounts.draft === 1 ? '' : 's'}`
|
||||
: `${dockCounts.deployed} deployed`
|
||||
)
|
||||
let editsOpen = $state(false)
|
||||
|
||||
// Only draft-vs-deployed drives the color: stale/failed live in the drawer's
|
||||
@@ -262,6 +268,7 @@
|
||||
maxHeightClass="max-h-[min(9rem,50vh)]"
|
||||
>
|
||||
{#snippet row(item)}
|
||||
<RowIcon kind={item.deployKind} path={item.path} size={14} />
|
||||
<span class="min-w-0 flex-1 truncate font-mono font-normal text-primary">
|
||||
{item.displayPath}
|
||||
</span>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
import { withWorkspaceParam } from '$lib/components/sessions/sessionMode.svelte'
|
||||
import { enterSessionMode } from '$lib/components/sessions/sessionSwitch.svelte'
|
||||
import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte'
|
||||
import { userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
getOrCreateRuntime,
|
||||
getRuntime,
|
||||
@@ -130,6 +130,11 @@
|
||||
// not-found UI below.
|
||||
$effect(() => {
|
||||
if (embedded || !sessionState.hydrated) return
|
||||
// Family membership can't be judged before the workspace list arrives:
|
||||
// workspaceRootId falls back to the raw id for workspaces it can't find,
|
||||
// which makes a same-family session look foreign on a hard reload and
|
||||
// would bounce the URL to another (or a brand-new) session.
|
||||
if ($usersWorkspaceStore === undefined) return
|
||||
// sessionInCurrentFamily reads these via get(), so track them explicitly.
|
||||
$workspaceStore
|
||||
$userWorkspaces
|
||||
|
||||
Reference in New Issue
Block a user