diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index 867268cd1a..2614adf445 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -14,7 +14,13 @@ import { editUrlFor } from './sessions/forkEditUrl' import { AppService, FlowService, ScriptService, type WorkspaceItemDiff } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { getDraftDiffValues, deployDraft, discardDraft } from '$lib/utils_draft_deploy' + import { + getDraftDiffValues, + deployDraft, + discardDraft, + draftBaseIsStale + } from '$lib/utils_draft_deploy' + import { checkDeployPermission, type DeployPermission } from '$lib/utils_workspace_deploy' import { type DraftItem, useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import type { Kind as LayoutKind } from '$lib/utils_deployable' import { userStore } from '$lib/stores' @@ -35,6 +41,15 @@ deployCount?: number updateCount?: number draftCount?: number + /** When set (reached via a session's Review button), preselect only the + * rows this chat modified — `${UserDraftItemKind}:${path}` keys, matching + * Row.key. Undefined → preselect all deployable rows (the default). All rows + * are still shown either way. */ + chatMask?: Set + /** False while the (async) chatMask is still loading. The select-all default + * waits for this so it doesn't race the mask and select everything. Defaults + * to true for callers that don't pass a mask. */ + chatMaskReady?: boolean /** Selecting deploy_to/update asks the page to swap to CompareWorkspaces. */ onModeSelected?: (v: CompareMode) => void /** Fired after a deploy/discard so the page can refresh the *fork* @@ -52,6 +67,8 @@ deployCount = 0, updateCount = 0, draftCount = 0, + chatMask, + chatMaskReady = true, onModeSelected, onChanged }: Props = $props() @@ -216,27 +233,11 @@ path: item.path, getDraft: true }))) as any - // A draft is stale when the version it forked from no longer matches the - // current deployed head: a newer version was deployed after the draft began. - // Scripts compare `parent_hash` vs the deployed `hash`; flows the pinned - // `version_id` vs the deployed head `version_id`; apps the pinned - // `parent_version` vs the deployed head (`versions[last]`). const draftBlob = r.draft as any - const appHead = Array.isArray(r.versions) ? r.versions[r.versions.length - 1] : undefined - const stale = - item.draftKind === 'script' - ? !!r.hash && !!draftBlob?.parent_hash && draftBlob.parent_hash !== r.hash - : item.draftKind === 'flow' - ? r.version_id != null && - draftBlob?.version_id != null && - draftBlob.version_id !== r.version_id - : appHead != null && - draftBlob?.parent_version != null && - draftBlob.parent_version !== appHead summaryCache[item.key] = { deployed: r.summary, draft: draftBlob?.summary, - stale, + stale: draftBaseIsStale(item.draftKind, r), loading: false } } catch (error) { @@ -263,6 +264,21 @@ let selectedItems = $state([]) let deploying = $state(false) + + // Whether the user may deploy drafts into this workspace — fills the + // `RestrictDeployToDeployers` (+ operator) gap via the shared util, same as the + // fork compare page and the session review drawer. Fail-open while resolving. + let deployPerm = $state({ ok: true }) + $effect(() => { + const ws = currentWorkspaceId + // Reset to fail-open on workspace change, and drop a stale resolution — + // otherwise the previous workspace's verdict lingers (or lands last) and + // gates the wrong workspace. + deployPerm = { ok: true } + void checkDeployPermission(ws).then((p) => { + if (ws === currentWorkspaceId) deployPerm = p + }) + }) // Select all on the first non-empty load (deploy-all is the common intent); // only once, so a refetch after a deploy doesn't re-select the leftovers. let hasAutoSelected = $state(false) @@ -286,8 +302,13 @@ }) $effect(() => { - if (!hasAutoSelected && visibleItems.length > 0) { - selectedItems = visibleItems.filter(isSelectable).map((i) => i.key) + if (!hasAutoSelected && chatMaskReady && visibleItems.length > 0) { + // Default intent is deploy-all; when reached from a session's Review + // (chatMask set), preselect only that chat's items instead. + const selectable = visibleItems.filter(isSelectable) + selectedItems = (chatMask ? selectable.filter((i) => chatMask.has(i.key)) : selectable).map( + (i) => i.key + ) hasAutoSelected = true } }) @@ -695,15 +716,19 @@ {/snippet} {#snippet footer()} -
+
+ {#if !deployPerm.ok} + {deployPerm.reason} + {/if}
{/snippet} diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 8dd30ff3f3..8cfa311141 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -37,10 +37,12 @@ import type { Kind } from '$lib/utils_deployable' import { + checkDeployPermission, deployItem, deleteItemInWorkspace, getItemValue, getOnBehalfOf, + type DeployPermission, type DeployResult } from '$lib/utils_workspace_deploy' import { isTriggerOrScheduleKind } from 'windmill-utils-internal' @@ -58,6 +60,7 @@ import { base } from '$lib/base' import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte' import { editUrlFor } from './sessions/forkEditUrl' + import { diffInMask } from './sessions/modifiedItemsMask' import DatatableSchemaDiff from './DatatableSchemaDiff.svelte' interface Props { @@ -77,6 +80,14 @@ * out of the default selection — deploying/updating moves the deployed * version, not the draft. The page derives this from the fork drafts. */ draftKeys?: Set + /** When set (reached via a session's Review button), preselect only the + * diffs this chat caused (matched via diffInMask). The deploy_to default + * narrows to these; the update direction (parent→fork) preselects nothing, + * since it's never chat-caused. All rows are still shown. */ + chatMask?: Set + /** False while the (async) chatMask is still loading. The select-all default + * waits for this so it doesn't race the mask. Defaults to true. */ + chatMaskReady?: boolean /** Selecting `draft` asks the page to swap us out for CompareDrafts; * deploy_to/update are handled internally but reported so the page can * remember the direction. */ @@ -95,6 +106,8 @@ updateCount = 0, draftCount = 0, draftKeys = new Set(), + chatMask, + chatMaskReady = true, onModeSelected, onChanged }: Props = $props() @@ -752,11 +765,19 @@ // to parent" flow. The user picks them à la carte by clicking the row. // Items with a pending draft are also left out by default: the deployed // version (not the draft) is what moves, so we make the user opt in. + // The update direction (parent→fork) is never something the chat caused, so + // when scoped to a chat's items (chatMask set) preselect nothing there. + if (chatMask && !mergeIntoParent) { + selectedItems = [] + return + } const filtered = selectableDiffs.filter((d) => !isTriggerOrScheduleKind(d.kind) && !hasDraft(d)) const conflictSafe = mergeIntoParent ? filtered : filtered.filter((d) => !(d.ahead > 0 && d.behind > 0)) - selectedItems = conflictSafe + // When reached from a session's Review, narrow the default to this chat's items. + const scoped = chatMask ? conflictSafe.filter((d) => diffInMask(d, chatMask)) : conflictSafe + selectedItems = scoped .map((d) => getItemKey(d)) .filter((k) => !(deploymentStatus[k]?.status == 'deployed')) } @@ -798,6 +819,21 @@ fetchPermissions() }) + // Can the user actually deploy into the target workspace? Fills the frontend + // gap for the `RestrictDeployToDeployers` rule (+ operator), shared with the + // session review drawer via the same checkDeployPermission util. Cached per + // workspace; `deployPerm` tracks whichever side the current direction targets. + let deployPerms = $state>({}) + const deployPermFetched = new Set() + $effect(() => { + for (const ws of [currentWorkspaceId, parentWorkspaceId]) { + if (!ws || deployPermFetched.has(ws)) continue + deployPermFetched.add(ws) + void checkDeployPermission(ws).then((p) => (deployPerms = { ...deployPerms, [ws]: p })) + } + }) + let deployPerm = $derived(deployPerms[deployTargetWorkspace] ?? { ok: true }) + // Fetch summaries and on_behalf_of_email when comparison data loads $effect(() => { if (comparison?.diffs) { @@ -808,7 +844,7 @@ // Auto-select items on initial load $effect(() => { - if (comparison?.diffs && !hasAutoSelected && selectableDiffs.length > 0) { + if (comparison?.diffs && !hasAutoSelected && chatMaskReady && selectableDiffs.length > 0) { selectDefault() hasAutoSelected = true } @@ -1361,7 +1397,9 @@ deploying || (hasBehindChanges && !allowBehindChangesOverride) || (mergeIntoParent && !canDeployToParent) || + !deployPerm.ok || hasUnselectedOnBehalfOf} + title={!deployPerm.ok ? deployPerm.reason : undefined} loading={deploying} on:click={requestDeploy} > @@ -1372,6 +1410,9 @@ {/if}
+ {#if !deployPerm.ok} + {deployPerm.reason} + {/if} {#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf} You must set the "on behalf of" user for all items before deploying diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 75cc6cec0b..1527feef73 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -38,6 +38,10 @@ /** Offer "Load" alongside "View Diff" on other users' rows. The deploy / * review page sets this false: loading into a fresh editor is moot there. */ allowFork?: boolean + /** Compact variant: render only the avatar circles (no "Draft" pill text), + * slightly smaller — for tight spots like the diff-tree sidebar. The hover + * popover is unchanged. */ + iconOnly?: boolean } let { @@ -49,7 +53,8 @@ itemKind = undefined, path = undefined, onMigrated = undefined, - allowFork = true + allowFork = true, + iconOnly = false }: Props = $props() // Authed user lands first; everyone else keeps the backend's ordering. @@ -193,6 +198,30 @@ } +{#snippet circleStack()} + + + {#each visibleUsers as u, i (i)} + + {initials(u)} + + {/each} + {#if overflowCount > 0} + + +{overflowCount} + + {/if} + +{/snippet} + {#if showBadge} @@ -204,32 +233,23 @@ bind:isOpen={popoverOpen} > {#snippet trigger()} - - {#if orderedUsers.length > 0} - - - {#each visibleUsers as u, i (i)} - - {initials(u)} - - {/each} - {#if overflowCount > 0} - - +{overflowCount} - - {/if} - - {/if} - {draft_only ? 'Draft only' : 'Draft'} - + {#if iconOnly} + + + + {#if orderedUsers.length > 0} + {@render circleStack()} + {/if} + + {:else} + + {#if orderedUsers.length > 0} + {@render circleStack()} + {/if} + {draft_only ? 'Draft only' : 'Draft'} + + {/if} {/snippet} {#snippet content()}
diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index cf7a7cad66..c57d41029c 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -90,7 +90,7 @@ const hovers: Partial> = { gray: 'hover:bg-surface-hover', - blue: 'hover:bg-blue-200 dark:hover:bg-blue-700/40', + blue: 'hover:bg-blue-100 dark:hover:bg-blue-700/60', red: 'hover:bg-red-200 dark:hover:bg-red-500/25', green: 'hover:bg-green-200 dark:hover:bg-green-500/25', yellow: 'hover:bg-yellow-200 dark:hover:bg-yellow-500/25', diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 5f5835bd13..c67bcd17b5 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -44,6 +44,9 @@ import { buildSummaryMessageContent } from './compactionPrompt' import { dfs } from '$lib/components/flows/previousResults' +import { SvelteSet } from 'svelte/reactivity' +import type { UserDraftItemKind } from '$lib/gen' +import { maskKey } from '$lib/components/sessions/modifiedItemsMask' import { getStringError } from './utils' import { type PasteAttachment } from './pasteTokens' import { chatDraft, expanded } from './chatDraft' @@ -343,6 +346,75 @@ export class AIChatManager { // session rather than the UI-active one — keeps backgrounded sessions isolated. sessionId: string | undefined = undefined + // Fired whenever the active chat id changes away from the one the consumer + // knows (a "/clear" rotation or a history switch). Session runtimes wire this + // to keep the session record's chatId aligned — the compare-page handoff + // (`from_session`) reads it, and a stale id would preselect the previous + // chat's items. Set here (not imported) to avoid a copilot→sessions cycle. + onChatRotated: ((chatId: string) => void) | undefined = undefined + + // Workspace items the CURRENT chat modified via AI tool calls, as + // `${UserDraftItemKind}:${storagePath}` keys (see modifiedItemsMask.ts). + // undefined = untracked: the global side-panel chat (never initialised) and + // loaded legacy chats with no stored mask, both of which fall back to the + // show-all bar. A SvelteSet (even empty) = tracked. Reactive so the session + // bar updates as tools record mid-turn. + modifiedItems = $state | undefined>(undefined) + + // Start tracking for a brand-new session chat (empty = "tracked, nothing yet"). + initModifiedItemsTracking() { + this.modifiedItems = new SvelteSet() + } + + // Record an item an AI tool call created/edited/deleted. No-op when untracked + // (the global singleton never initialises the set), so it stays unaffected. + recordModifiedItem(itemKind: UserDraftItemKind, storagePath: string) { + this.modifiedItems?.add(maskKey(itemKind, storagePath)) + } + + // Un-record an item whose chat-made change was discarded — without this the + // still-existing deployed item would keep reading as this chat's "Deployed" + // edit. Persisted immediately: unlike recordModifiedItem (whose persistence + // rides on the turn's saveChat), a discard can fire from the review dock + // outside any turn, and waiting would resurrect the entry on reload. + async removeModifiedItem(itemKind: UserDraftItemKind, storagePath: string) { + if (!this.modifiedItems?.delete(maskKey(itemKind, storagePath))) return + await this.#persistModifiedItems() + } + + // Move a mask entry to the path a draft actually deployed to. A draft-only + // flow/app parks at a synthetic `draft_{uuid}` storage path and deploys to + // its chosen path — without the move, the existence check at the synthetic + // path fails after reload and the deployed row vanishes from the dock. + async renameModifiedItem(itemKind: UserDraftItemKind, fromPath: string, toPath: string) { + if (fromPath === toPath) return + if (!this.modifiedItems?.delete(maskKey(itemKind, fromPath))) return + this.modifiedItems.add(maskKey(itemKind, toPath)) + await this.#persistModifiedItems() + } + + // Serialized, snapshot-at-write-time persistence: two rapid dock actions + // would otherwise race their saveChat writes, and the earlier (staler) + // snapshot could land last — dropping the later mutation until the next + // turn-end save. + #maskPersistQueue: Promise = Promise.resolve() + #persistModifiedItems(): Promise { + this.#maskPersistQueue = this.#maskPersistQueue.then(() => + this.historyManager + .saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) + // Swallow (and log) a failed write so it can't wedge the queue as a + // rejected link — the next persist snapshots the full current set, so + // a lost write self-heals on the next mutation or turn-end save. + .catch((e) => console.error('Failed to persist modified-items mask', e)) + ) + return this.#maskPersistQueue + } + // Workspace AI skills (name + description) advertised in the GLOBAL system // prompt and surfaced as slash commands in session chat. Loaded // asynchronously when entering GLOBAL mode; the system message is rebuilt @@ -659,7 +731,12 @@ export class AIChatManager { ) switch (result) { case 'ok': - await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + await this.historyManager.saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) sendUserToast('Conversation compacted.') break case 'empty': @@ -1636,7 +1713,12 @@ export class AIChatManager { const projectedContextTokens = this.contextTokens + this.estimateMessagesTokens([userMessage]) this.messages.push(userMessage) - await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + await this.historyManager.saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) this.currentReply = '' this.currentReasoning = '' @@ -1672,7 +1754,12 @@ export class AIChatManager { this.contextUsage = Math.max(0, this.contextUsage - freed) } } - await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + await this.historyManager.saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) } } // Rollback anchors for restoreUnsentTurn: captured after compaction so @@ -1758,7 +1845,10 @@ export class AIChatManager { }, requestConfirmation: this.requestConfirmation, shouldAutoAcceptToolConfirmations: () => this.autoAcceptToolConfirmationsActive, - requestUserQuestion: this.requestUserQuestion + requestUserQuestion: this.requestUserQuestion, + onItemModified: (kind, path) => this.recordModifiedItem(kind, path), + onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to), + onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path) } } @@ -1794,7 +1884,12 @@ export class AIChatManager { this.contextUsage = result?.lastIterationUsage ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion : undefined - await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + await this.historyManager.saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) // Still counts as the saved first turn — skipping the hook here would // permanently miss it (the next turn isn't "first" anymore). if (isFirstUserTurn && this.afterFirstTurnSaved) { @@ -1824,7 +1919,12 @@ export class AIChatManager { // user message on reload. Remove it instead. this.historyManager.deletePastChat(this.historyManager.getCurrentChatId()) } else { - await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + await this.historyManager.saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) } if (!wasAborted) { sendUserToast('The model returned no response — your message was restored to the input.') @@ -1843,7 +1943,12 @@ export class AIChatManager { if (this.autoAcceptEditsActive) { this.acceptPendingFlowEdits() } - await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + await this.historyManager.saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) // Only this branch is a clean send: the queued-message flush below // auto-sends the next message after it (set after saveChat so a // persistence failure falls through to the restore path instead). @@ -1867,7 +1972,12 @@ export class AIChatManager { // compaction on the next send instead of failing the same way again. this.contextUsage = undefined try { - await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextUsage) + await this.historyManager.saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) } catch (saveErr) { console.error('Failed to persist partial chat after error', saveErr) } @@ -1996,15 +2106,25 @@ export class AIChatManager { // Drop any message queued in this conversation so it can't auto-send into // the fresh chat or linger as a card across the switch. this.queuedMessage = '' - await this.historyManager.save(this.displayMessages, this.messages, this.contextUsage) + await this.historyManager.save( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) this.displayMessages = [] this.messages = [] this.contextUsage = undefined + // The mask belongs to the conversation just saved — the fresh chat starts + // its own (empty) tracking; carrying entries over would claim the previous + // conversation's edits for the new one. Untracked chats stay untracked. + if (this.modifiedItems) this.modifiedItems = new SvelteSet() // In an AI session, linked files are session-scoped: they persist across conversations // (cleared only when the session is deleted). The ephemeral global side-panel chat has no // session, so "New chat" must clear them — otherwise the next, unrelated conversation // would still get the previous file roster and could read/search it. if (!this.isSessionChat) this.attachedFiles.clear() + this.onChatRotated?.(this.historyManager.getCurrentChatId()) } loadPastChat = async (id: string) => { @@ -2019,7 +2139,16 @@ export class AIChatManager { this.displayMessages = chat.displayMessages this.messages = chat.actualMessages this.contextUsage = normalizeContextUsage(chat.contextUsage) + // Seed the modified-items mask from the stored chat. A stored array + // (even empty) → tracked; a legacy chat with no field stays untracked + // (undefined) so the session bar falls back to showing all drafts. The + // global side-panel chat never tracks, so leave it untouched there. + if (this.isSessionChat) { + const stored = this.historyManager.getModifiedItems(id) + this.modifiedItems = stored !== undefined ? new SvelteSet(stored) : undefined + } this.#automaticScroll = true + this.onChatRotated?.(id) } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index d23cbf96a1..a415377a7b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -693,7 +693,9 @@ describe('AIChatManager context compaction', () => { expect(manager.messages[0]).toMatchObject({ role: 'user', content: 'c'.repeat(400) }) // Mid-turn, the report is debited by the freed estimate (visible in the // compaction-time save) so a rolled-back turn keeps a consistent value - expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 650_000) + // 4th arg: the modified-items mask rides on every save (undefined here — + // this bare manager never initialised tracking). + expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 650_000, undefined) // At commit, the no-report turn clears the stored value; the readable // number falls back to estimating the now-tiny compacted history expect(manager.contextUsage).toBeUndefined() diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index 9173e4c441..0db0751293 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -25,6 +25,12 @@ interface ChatSchema extends IDBSchema { // New writes store the plain reported token count; chats persisted by // earlier versions may still hold the legacy anchor object. contextUsage?: PersistedContextUsage + // Workspace items this chat modified via AI tool calls, as + // `${UserDraftItemKind}:${storagePath}` keys. Persisted out-of-band from + // the message arrays so it survives compaction. Absent (undefined) on + // chats predating this feature → consumers fall back to showing all + // workspace drafts; a defined array (even empty) means "tracked". + modifiedItems?: string[] } } } @@ -80,6 +86,29 @@ export function __resetLegacyChatClaimForTesting(): void { legacyChatClaim = undefined } +// Read a chat's modified-items mask by chatId WITHOUT mounting an AIChatManager, +// for the standalone /forks/compare route. Returns undefined for a legacy chat +// (no field) so the page falls back to selecting all items; a defined array +// (even empty) narrows the preselection. Opens a throwaway user-scoped handle; +// the `get` is O(1) on the `id` keyPath. +export async function readChatModifiedItems(chatId: string): Promise { + const dbh = userScopedDb(DB_NAME, { + version: 1, + upgrade: createChatStore, + migrate: migrateLegacyChatDb + }) + try { + const db = await dbh.whenReady() + const chat = await db?.get('chats', chatId) + return chat?.modifiedItems + } catch (err) { + console.error('Could not read chat modified items', err) + return undefined + } finally { + dbh.close() + } +} + export default class HistoryManager { // Per-instance handle to the shared per-user DB lifecycle. There is one // HistoryManager per AIChatManager (the singleton + one per session runtime), @@ -100,6 +129,7 @@ export default class HistoryManager { lastModified: number sessionId?: string contextUsage?: PersistedContextUsage + modifiedItems?: string[] } > = $state({}) @@ -173,10 +203,15 @@ export default class HistoryManager { return Object.values(this.savedChats) } + getModifiedItems(id: string): string[] | undefined { + return this.savedChats[id]?.modifiedItems + } + async saveChat( displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[], - contextUsage?: number + contextUsage?: number, + modifiedItems?: string[] ) { if (displayMessages.length > 0) { // Compaction replaces the original first message with a summary boundary. @@ -203,7 +238,18 @@ export default class HistoryManager { id: this.currentChatId, lastModified: Date.now(), ...(this.sessionId ? { sessionId: this.sessionId } : {}), - ...(contextUsage !== undefined ? { contextUsage } : {}) + ...(contextUsage !== undefined ? { contextUsage } : {}), + // Only persist when the caller passes a defined array. Loaded legacy + // chats keep their accumulator undefined, so we never retroactively + // stamp them with [] (which would flip them to the filtered view). + // But since `put` replaces the whole record, a caller that omits the + // argument must not ERASE a tracked chat's stored mask — fall back to + // the previously saved field. + ...(modifiedItems !== undefined + ? { modifiedItems } + : this.savedChats[this.currentChatId]?.modifiedItems !== undefined + ? { modifiedItems: this.savedChats[this.currentChatId].modifiedItems } + : {}) } this.savedChats = { ...this.savedChats, @@ -218,9 +264,10 @@ export default class HistoryManager { async save( displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[], - contextUsage?: number + contextUsage?: number, + modifiedItems?: string[] ) { - await this.saveChat(displayMessages, messages, contextUsage) + await this.saveChat(displayMessages, messages, contextUsage, modifiedItems) this.currentChatId = createLongHash() } diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts index 38fb7ecf0d..540dfaf72e 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.test.ts @@ -141,3 +141,30 @@ describe('HistoryManager title across compaction', () => { expect(hm.getAllSavedChats().find((c) => c.id === id)?.title).toBe('original first question') }) }) + +describe('HistoryManager modified-items mask persistence', () => { + const msgs = [{ role: 'user', content: 'hello', index: 0 }] as DisplayMessage[] + + it('a save without the argument preserves a previously stored mask', async () => { + const hm = new HistoryManager() + await hm.init() + const id = hm.getCurrentChatId() + + await hm.saveChat(msgs, [] as ChatCompletionMessageParam[], undefined, ['script:u/a/x']) + expect(hm.getModifiedItems(id)).toEqual(['script:u/a/x']) + + // e.g. manual compaction re-saving the transcript: the whole record is + // rewritten, but the tracked mask must survive. + await hm.saveChat(msgs, [] as ChatCompletionMessageParam[]) + expect(hm.getModifiedItems(id)).toEqual(['script:u/a/x']) + }) + + it('never retroactively stamps an untracked chat', async () => { + const hm = new HistoryManager() + await hm.init() + const id = hm.getCurrentChatId() + + await hm.saveChat(msgs, [] as ChatCompletionMessageParam[]) + expect(hm.getModifiedItems(id)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 90dcb655f4..24045280d0 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -114,6 +114,7 @@ import { getEphemeralSecretVariableDraftValue, getGlobalDraft, getGlobalDraftStoragePath, + itemKindFor, listGlobalDrafts, persistGlobalDraft, readGlobalDraftValue, @@ -2779,6 +2780,7 @@ function finishAppDraftWrite( ): string { const failure = draftWriteFailure(result, ctx) if (failure) return failure + ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath) const { content, message } = onSaved() ctx.toolCallbacks.setToolStatus(ctx.toolId, { content, result: 'Saved as draft' }) return JSON.stringify({ success: true, message }, null, 2) @@ -2791,6 +2793,7 @@ function finishDraftWrite( ): string { const failure = draftWriteFailure(result, ctx) if (failure) return failure + ctx.toolCallbacks.onItemModified?.(result.itemKind, result.storagePath) const stored = result.item const verb = existed ? 'Updated' : 'Created' // Don't echo the flow value back: the model just sent it in the write call, @@ -3898,6 +3901,16 @@ async function discardLocalDraft( await deleteGlobalDraft(workspace, type, path, triggerKind) + // The chat's touch on the item is undone — drop it from the mask so a + // pre-existing deployed item doesn't keep reading as this chat's edit. + const discardedKind = itemKindFor(type, triggerKind) + if (discardedKind) { + toolCallbacks.onItemDiscarded?.( + discardedKind, + getGlobalDraftStoragePath(workspace, type, path, triggerKind) + ) + } + toolCallbacks.setToolStatus(toolId, { content: `Discarded ${type} "${path}" draft`, result: 'Draft discarded' @@ -4237,6 +4250,9 @@ async function deployDraft( }) let actions: ToolDisplayAction[] | undefined + // Where the deploy actually lands — the app branch can resolve a different + // target from the draft's own path fields; the mask rename below must track it. + let deployedPath = path if (type === 'script' || type === 'flow') { // Promote the full persisted draft via the shared deploy module — the same @@ -4429,6 +4445,7 @@ async function deployDraft( throw e } } + deployedPath = targetPath if (await AppService.existsApp({ workspace, path: targetPath })) { // Omit custom_path on update for now. The backend preserves it when absent, while // sending it requires admin privileges; this chat deploy path does not yet mirror @@ -4478,6 +4495,18 @@ async function deployDraft( await deleteGlobalDraft(workspace, type, path, triggerKind, { preserveLiveDraft: true }) + // Move the chat's mask entry to the deployed path: a draft-only item's + // synthetic storage key never exists deployed, so the entry would otherwise + // stop matching anything after the draft is gone. + const deployedKind = itemKindFor(type, triggerKind) + if (deployedKind) { + toolCallbacks.onItemDeployed?.( + deployedKind, + getGlobalDraftStoragePath(workspace, type, path, triggerKind), + deployedPath + ) + } + // Reload the session preview if it's open on the deployed item. Map the // deploy type to the preview kind — a raw app deploys under 'app' but the // preview addresses it as 'raw_app'; non-previewable types map to undefined. @@ -4548,6 +4577,17 @@ async function deleteWorkspaceItem( await deleteGlobalDraft(workspace, type, path, triggerKind) + // Record the deletion in the chat's modified-items mask. In a fork this leaves a + // reviewable "removed" diff vs the parent that stays scoped to this chat. Keyed + // by the same (itemKind, storagePath) as writes so it joins the draft/fork lists. + const deletedKind = itemKindFor(type, triggerKind) + if (deletedKind) { + toolCallbacks.onItemModified?.( + deletedKind, + getGlobalDraftStoragePath(workspace, type, path, triggerKind) + ) + } + toolCallbacks.setToolStatus(toolId, { content: `Deleted ${type} "${path}"`, result: 'Deleted' diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 87ca767904..2717d41608 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -108,7 +108,7 @@ function clearEphemeralSecretVariableDraftValues(workspace: string): void { secretVariableDraftValues.delete(workspace) } -function itemKindFor( +export function itemKindFor( type: WorkspaceItemType, triggerKind?: TriggerKind ): UserDraftItemKind | undefined { @@ -377,10 +377,25 @@ export async function readGlobalDraftValue( return (await fetchBackendDraftValue(workspace, itemKind, storagePath)) as V | undefined } +// `itemKind` + `storagePath` are the canonical identity of the persisted draft +// (NOT item.path, which is the friendly display path). Callers use them to record +// the chat's modified-items mask. export type DraftPersistResult = - | { status: 'saved'; item: WorkspaceItem } - | { status: 'conflict'; item: WorkspaceItem; serverTimestamp?: string } - | { status: 'error'; item: WorkspaceItem; message: string } + | { status: 'saved'; item: WorkspaceItem; itemKind: UserDraftItemKind; storagePath: string } + | { + status: 'conflict' + item: WorkspaceItem + itemKind: UserDraftItemKind + storagePath: string + serverTimestamp?: string + } + | { + status: 'error' + item: WorkspaceItem + itemKind: UserDraftItemKind + storagePath: string + message: string + } // Persist a built draft value. `UserDraft.seed` reflects it into an open editor's // cell WITHOUT a double-POST (no-ops if no cell; its seedNextWrite suppresses the @@ -417,14 +432,20 @@ export async function persistGlobalDraft( // the chat "saved" while the DB-backed source of truth was never updated. const saveState = UserDraftDbSyncer.getState({ workspace, itemKind, path: storagePath }) if (saveState.state === 'failed') { - return { status: 'error', item, message: saveState.failureMessage ?? 'Draft save failed' } + return { + status: 'error', + item, + itemKind, + storagePath, + message: saveState.failureMessage ?? 'Draft save failed' + } } const conflict = opts.force ? undefined : UserDraftDbSyncer.getConflict({ workspace, itemKind, path: storagePath }).conflict return conflict - ? { status: 'conflict', item, serverTimestamp: conflict.serverTimestamp } - : { status: 'saved', item } + ? { status: 'conflict', item, itemKind, storagePath, serverTimestamp: conflict.serverTimestamp } + : { status: 'saved', item, itemKind, storagePath } } export async function getGlobalDraft( diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 7abdc75168..c454f15fe6 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -3,6 +3,7 @@ import type { ChatCompletionMessageFunctionToolCall, ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' +import type { UserDraftItemKind } from '$lib/gen' /** * Special module IDs used throughout the flow system @@ -759,6 +760,15 @@ export interface ToolCallbacks { toolId: string, question: UserQuestionDisplay ) => Promise + /** Records a workspace item the tool call created/edited/deleted, by its + * canonical (itemKind, storagePath). Session chats wire this to accumulate the + * chat's modified-items mask; the global side-panel chat omits it (no-op). */ + onItemModified?: (itemKind: UserDraftItemKind, storagePath: string) => void + /** A tool deployed a draft: the mask entry moves from the draft's storage path + * to the deployed path (they differ for synthetic draft-only storage keys). */ + onItemDeployed?: (itemKind: UserDraftItemKind, storagePath: string, deployedPath: string) => void + /** A tool discarded a draft: the chat's touch on the item is undone. */ + onItemDiscarded?: (itemKind: UserDraftItemKind, storagePath: string) => void } export function createToolDef( diff --git a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte deleted file mode 100644 index 0b529b2056..0000000000 --- a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte +++ /dev/null @@ -1,204 +0,0 @@ - - - buildEditUrl(d as unknown as WorkspaceItemDiff, workspaceId)} -> - {#snippet titleExtra()} -
- - {ws?.name ?? workspaceId} -
- {/snippet} -
diff --git a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte b/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte deleted file mode 100644 index e03fc5a49c..0000000000 --- a/frontend/src/lib/components/sessions/ForkDiffDrawer.svelte +++ /dev/null @@ -1,125 +0,0 @@ - - - buildEditUrl(d as unknown as WorkspaceItemDiff, forkWorkspaceId)} -> - {#snippet titleExtra()} -
- - {forkWs?.name ?? forkWorkspaceId} - - {parentWs?.name ?? parentWorkspaceId} - {#if comparison} - - {comparison.summary.total_diffs} item{comparison.summary.total_diffs !== 1 ? 's' : ''} - - {#if comparison.summary.conflicts > 0} - - - {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 ? 's' : ''} - - {/if} - {/if} -
- {/snippet} -
diff --git a/frontend/src/lib/components/sessions/SessionChangesBar.svelte b/frontend/src/lib/components/sessions/SessionChangesBar.svelte new file mode 100644 index 0000000000..9f59d1f320 --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionChangesBar.svelte @@ -0,0 +1,264 @@ + + +{#snippet dock()} + +
+ {#if dockCounts.draft > 0} + diffDrawer?.open()}> + {dockCounts.draft} draft{dockCounts.draft === 1 ? '' : 's'} + + {/if} + {#if dockCounts.deployed > 0} + diffDrawer?.open()}> + {dockCounts.deployed} deployed + + {/if} + {#if deletionOnly && compareHref} + + + Review deletions on compare page + + + {/if} +
+{/snippet} + +{#if committedId && isUnavailable} + +
+
+ +
+ The {committedIsFork ? 'fork' : 'workspace'} has been archived or deleted + + Move this session to another workspace, or discard it. + {committedId} + +
+
+
+ onMove?.(workspaceId)} + onCreateFork={async (fork) => { + await onCreateForkAndMove?.(fork) + }} + createForkCaption="Created immediately and the session moved into it." + > + {#snippet trigger()} + + {/snippet} + + +
+
+{:else if showBar && committedId} + +
+
+ + Edits +
+ {@render dock()} +
+{/if} + + +{#if committedId && !isUnavailable} + + void runtime?.manager.renameModifiedItem(item.draftKind, item.path, item.displayPath)} + onItemDiscarded={(item) => void runtime?.manager.removeModifiedItem(item.draftKind, item.path)} + /> +{/if} diff --git a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte new file mode 100644 index 0000000000..1a6692048c --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte @@ -0,0 +1,107 @@ + + + + {#snippet titleExtra()} +
+ {#if isFork} + + + {ws?.name ?? workspaceId} + + + + {parentWs?.name ?? parentWorkspaceId} + + {:else} + + + {ws?.name ?? workspaceId} + + {/if} +
+ {/snippet} +
diff --git a/frontend/src/lib/components/sessions/SessionDraftBar.svelte b/frontend/src/lib/components/sessions/SessionDraftBar.svelte deleted file mode 100644 index d23070151a..0000000000 --- a/frontend/src/lib/components/sessions/SessionDraftBar.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - -{#if committedId && count > 0} -
-
- - - {count} draft{count === 1 ? '' : 's'} - {#snippet text()} - Tracks all unsaved draft changes in this workspace — including edits made outside this - chat (e.g. in the editor), not only changes made by the assistant. - {/snippet} - -
-
- drawer?.open()} /> - -
-
- - -{/if} diff --git a/frontend/src/lib/components/sessions/SessionForkBar.svelte b/frontend/src/lib/components/sessions/SessionForkBar.svelte deleted file mode 100644 index a0aafee456..0000000000 --- a/frontend/src/lib/components/sessions/SessionForkBar.svelte +++ /dev/null @@ -1,212 +0,0 @@ - - -{#if committedId && isUnavailable} - -
-
- -
- The {committedIsFork ? 'fork' : 'workspace'} has been archived or deleted - - Move this session to another workspace, or discard it. - {committedId} - -
-
-
- onMove?.(workspaceId)} - onCreateFork={async (fork) => { - await onCreateForkAndMove?.(fork) - }} - createForkCaption="Created immediately and the session moved into it." - > - {#snippet trigger()} - - {/snippet} - - -
-
-{:else if forksAllowed && isFork && sessionWorkspace && parentWorkspace && parentWorkspaceId && committedId} - {@const StatusIcon = - forkStatus === 'ahead' - ? GitPullRequestArrow - : forkStatus === 'diverged' - ? GitCompareArrows - : GitFork} - {@const statusColor = - forkStatus === 'ahead' - ? 'text-blue-500' - : forkStatus === 'diverged' - ? 'text-amber-500' - : 'text-secondary'} - {@const statusTitle = - forkStatus === 'ahead' - ? 'Ahead of parent' - : forkStatus === 'diverged' - ? 'Diverged from parent' - : forkStatus === 'in_sync' - ? 'In sync with parent' - : 'Fork'} -
-
- - - - - {sessionWorkspace.name} - - - - {parentWorkspace.name} - -
-
- diffDrawer?.open()} - /> - -
-
- - -{/if} diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index aad3de26c3..0342fd53e1 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -21,7 +21,6 @@ import { slide } from 'svelte/transition' import { createSession, - deriveForkStatus, deleteSessionsForWorkspace, isForkSession, reconcileAfterWorkspaceChange, @@ -53,15 +52,6 @@ import { sendUserToast } from '$lib/toast' import { currentWorkspaceRootId, workspaceRootId } from './sessionScope.svelte' - // Look up the cached fork comparison for a session through its runtime - // (if any). The deriveForkStatus helper handles the "no runtime yet" - // and "comparison not loaded" cases by returning undefined; we render - // a neutral fork icon in that interim, then upgrade to the proper - // status icon once the comparison lands. - function forkStatusFor(session: Session) { - return deriveForkStatus(session, $userWorkspaces, getRuntime(session.id)?.forkComparison.val) - } - function isForkFor(session: Session): boolean { return isForkSession(session, $userWorkspaces) } @@ -124,8 +114,8 @@ } // Flat list passing the archive + scope filters. Grouping for display happens - // in `sessionGroups`; this flat view drives the runtime / fork-comparison - // effects, the unread total, and keyboard navigation. + // in `sessionGroups`; this flat view drives the runtime effect, the unread + // total, and keyboard navigation. const visibleSessions = $derived( sessionState.sessions.filter((s) => { if (s.transient) return false @@ -211,23 +201,6 @@ } }) - // Pre-fetch the fork comparison for every visible fork session so the - // sidebar icons reflect the right ahead/diverged state without - // requiring the user to click into each session. Cheap enough at - // typical session counts; falls back to a plain dot until the - // fetch lands. - $effect(() => { - if (sectionCollapsed.val) return - for (const session of visibleSessions) { - if (!session.workspace_id) continue - const ws = $userWorkspaces.find((w) => w.id === session.workspace_id) - if (!ws?.parent_workspace_id) continue - const rt = getRuntime(session.id) - if (!rt) continue - void rt.ensureForkComparison(ws.parent_workspace_id, session.workspace_id) - } - }) - function isUnavailableFork(session: Session): boolean { return !!session.workspace_id && !$userWorkspaces.find((w) => w.id === session.workspace_id) } @@ -241,10 +214,6 @@ if (!isUnavailableFork(session)) { syncWorkspaceTo(session.workspace_id) } - // Refresh the fork diff count — users typically click back into a - // session after editing items elsewhere in the SPA, where neither - // the visibility-change nor the AI-loading signal would fire. - void getRuntime(session.id)?.refreshForkComparison() await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`) if (restoreFocus) { // goto() resets focus to — put it back on the active session button @@ -436,7 +405,7 @@ {session.summary ?? 'Untitled session'} {#if draft || unread > 0} diff --git a/frontend/src/lib/components/sessions/SessionStatusDot.svelte b/frontend/src/lib/components/sessions/SessionStatusDot.svelte index b47816ab19..35028c80cc 100644 --- a/frontend/src/lib/components/sessions/SessionStatusDot.svelte +++ b/frontend/src/lib/components/sessions/SessionStatusDot.svelte @@ -3,19 +3,16 @@ AlertCircle, AlertTriangle, Building, - GitCompareArrows, GitFork, - GitPullRequestArrow, GitPullRequestClosed } from 'lucide-svelte' import type { SessionChatStatus } from './sessionRuntime.svelte' - import type { ForkStatus } from './sessionState.svelte' let { status, isFork, - forkStatus - }: { status: SessionChatStatus; isFork: boolean; forkStatus?: ForkStatus } = $props() + unavailable = false + }: { status: SessionChatStatus; isFork: boolean; unavailable?: boolean } = $props() const statusTooltip: Record = { idle: 'No chat activity', @@ -26,13 +23,6 @@ error: 'Last message had an error' } - const forkTooltip: Record = { - in_sync: 'Fork — in sync with parent', - ahead: 'Fork — ahead of parent', - diverged: 'Fork — diverged from parent', - unavailable: 'Fork — no longer available' - } - // Live chat signals take precedence over the persistent kind/fork // indicator: streaming, needs-confirmation, and error are time-critical // and warrant briefly hijacking the icon slot. @@ -41,7 +31,11 @@ ) const persistentTitle = $derived( - isFork ? (forkStatus ? forkTooltip[forkStatus] : 'Fork session') : 'Root workspace session' + isFork + ? unavailable + ? 'Fork — no longer available' + : 'Fork session' + : 'Root workspace session' ) const title = $derived(liveOverride ? statusTooltip[status] : persistentTitle) @@ -59,11 +53,7 @@ {:else if status === 'error'} {:else if isFork} - {#if forkStatus === 'ahead'} - - {:else if forkStatus === 'diverged'} - - {:else if forkStatus === 'unavailable'} + {#if unavailable} {:else} diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index c9fdb6454f..ee8cbed6e0 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -30,8 +30,7 @@ import RawAppEditorView from './RawAppEditorView.svelte' import PipelineEditorView from './PipelineEditorView.svelte' import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' - import SessionForkBar from './SessionForkBar.svelte' - import SessionDraftBar from './SessionDraftBar.svelte' + import SessionChangesBar from './SessionChangesBar.svelte' import { createSession, deleteSessionsForWorkspace, @@ -235,7 +234,7 @@ // True when the session committed to a workspace that's no longer in // the user's list (deleted / archived / access revoked). The chat is - // disabled and SessionForkBar shows a move/discard banner. + // disabled and SessionChangesBar shows a move/discard banner. const isUnavailable = $derived( !!session?.workspace_id && !$userWorkspaces.find((w) => w.id === session!.workspace_id) ) @@ -274,16 +273,16 @@ {#if !hasFirstUserMessage} {/if} - +
{#if session.archived && !isUnavailable}
{/if} - moveAndActivate(workspaceId)} onCreateForkAndMove={(fork) => createForkAndMove(fork)} onArchive={() => archiveAndReset()} onDelete={() => (deleteConfirmOpen = true)} /> -
{/snippet} diff --git a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte index e6b4e5fd2f..50c6653b0a 100644 --- a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte @@ -1,45 +1,19 @@ - - searchableText(d)} + items={displayEntries} + bind:filteredItems={searchedEntries} + f={(e: DisplayEntry) => searchableText(e)} /> -{#snippet renderTreeNode(node: TreeNode, depth: number)} + +{#snippet rowBadge(item: DeployItem)} + {#if badgeOf(item) === 'draft'} + {#if model.staleOf(item.key)} + + + {#snippet trigger()} + + {/snippet} + {#snippet content()} +
+ Started from an older deployed version. A newer version was deployed after this draft + began. Review the latest deploy before deploying. +
+ {/snippet} +
+ {/if} + + {item.draftOnly ? 'Draft only' : 'Draft'} + + {:else} + + + + + + + {/if} +{/snippet} + + +{#snippet deployFailed(item: DeployItem)} + {@const s = model.statusOf(item.key)} + {#if s?.status === 'failed'} + + Failed + + + {/if} +{/snippet} + +{#snippet renderTreeNode(node: TreeNode, depth: number)} {#if node.type === 'folder'} {@const isUserScope = node.isScope && node.name.startsWith('u/')} {@const fkey = node.key} @@ -417,36 +613,62 @@ {@const isHl = fkey === highlightedKey}
(folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)} + ontoggle={(e) => { + // Record real user toggles only; skip the echo fired when the `open` + // attribute is driven by state (search force-open, expandApp). + const domOpen = (e.currentTarget as HTMLDetailsElement).open + if (domOpen !== isFolderOpen(fkey)) folderOpen[fkey] = domOpen + }} class="select-none" > {#if node.app} - {@const appSummary = summaries[node.app.summaryKey] ?? node.app.summary} - + {@const appItem = segmentItems.find((it) => it.key === node.app?.summaryKey)} + setHoverHighlight(fkey)} + onclick={(e) => { + e.preventDefault() + if (appItem) revealDiff(appItem, fkey) + }} title={node.fullPath} - class="flex items-center gap-1.5 px-3 py-1.5 cursor-pointer hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl + class="flex items-center gap-2 pl-3 pr-1 py-2 rounded-md cursor-pointer hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl ? 'bg-surface-hover' : ''}" style="padding-left: {depth * 12 + 8}px" > - {appSummary ?? node.name} + {node.app.summary ?? node.name} - - + {#if appItem} + {@render rowBadge(staged[appItem.key] ?? appItem)} + {/if} + + {:else} setHoverHighlight(fkey)} - class="flex items-center gap-1.5 px-3 py-1.5 cursor-pointer text-xs font-normal font-mono text-secondary hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl + class="flex items-center gap-2 pl-3 pr-1 py-2 rounded-md cursor-pointer text-xs font-normal font-mono text-secondary hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl ? 'bg-surface-hover' : ''}" style="padding-left: {depth * 12 + 8}px" @@ -465,14 +687,13 @@ {/if} {node.name} - - + + + + {/if}
-
- { - highlightedKey = key - scrollToDiff(d) - }} - onmouseenter={() => setHoverHighlight(key)} + +
+ {#if isSynthetic(d)} + void revealSynthetic(d, key)} + onmouseenter={() => setHoverHighlight(key)} + > + {#snippet extras()} + + {/snippet} + + {:else} + revealDiff(d, key)} + onmouseenter={() => setHoverHighlight(key)} + > + {#snippet extras()} + {@render rowBadge(staged[d.key] ?? d)} + {#if d.deployKind === 'raw_app'} + + { + e.stopPropagation() + expandApp(d) + }} + > + {#if loadedDiffs[d.key]?.state === 'loading'} + + {:else} + + {/if} + + {:else} + + {/if} + {/snippet} + + {/if} +
+ {/if} +{/snippet} + + +{#snippet diffBlock(item: DeployItem)} + {@const loaded = loadedDiffs[item.key]} + {#if !mountedRows[item.key]} +
+ + Diff loads on scroll… +
+ {:else if !loaded || loaded.state === 'loading'} +
+ + Loading diff… +
+ {:else if loaded.state === 'error'} +
{loaded.error}
+ {:else if item.deployKind === 'raw_app'} +
+ {#each rawAppItems(item, loaded) as sub (displayKey(sub))} + +
+
+ {sub.path} +
+ {#if sub.kind === 'raw_app_file'} + + {:else} + {@const runnable = sub as RawAppRunnableItem} + + {/if} +
+ {/each} +
+ {:else} + {/if} {/snippet} @@ -540,178 +869,215 @@ /> {/snippet} - {/snippet} -
- {#if diffs.length > 0} - - {/if} -
-
- {#if loading && diffs.length === 0} -
- - Loading comparison... +
+
+ {#if model.items.length > 0} + + {/if} +
+
+ {#if model.loading && model.items.length === 0} +
+ + Loading changes... +
+ {:else if model.error} +
{model.error}
+ {:else if model.items.length === 0} +
No changes.
+ {:else if orderedItems.length === 0} +
No files match.
+ {:else} +
+ {#each orderedItems as d (d.key)} + + {@const view = staged[d.key] ?? d} + {@const action = actionFor(view)} + {@const editUrl = editUrlFor?.(d)} + {@const status = model.statusOf(d.key)} +
- - -
- {#if editUrl} - + +
+ {#if editUrl} + + {d.displayPath} + + {:else} +
+ {d.displayPath} +
+ {/if} +
+
+ {@render rowBadge(view)} + {#if status?.status === 'failed'} + {@render deployFailed(d)} + {:else if action.op !== 'none'} + {#if action.secondary?.length} + + {/if} +
+ + {#if staged[d.key]} + +
+ +
+ {/if} +
+ {/if} +
+
+
+ + {#if view.done} +
- {dpath} - + Deployed — no pending changes. +
{:else} -
- {dpath} +
+ {@render diffBlock(view)}
{/if}
-
- {#if d.ahead && d.ahead > 0} - {d.ahead} ahead - {/if} - {#if d.behind && d.behind > 0} - {d.behind} behind - {/if} - - - {status} - -
- -
- {#if !mountedRows[key]} - -
- - Diff loads on scroll… -
- {:else if d.kind === 'raw_app_file'} - - {@const rawFile = d as RawAppFileItem} - - {:else if 'appPath' in d} - - {@const runnable = d as RawAppRunnableItem} - - {:else if !loaded || loaded.state === 'loading'} -
- - Loading diff… -
- {:else if loaded.state === 'error'} -
{loaded.error}
- {:else if loaded.state === 'ready'} - - {/if}
-
- {/each} -
- {/if} - + {/each} + + + + {/if} + + + + + {#if model.items.length > 0 && compareSessionHref} + + {/if} + +