mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 00:03:08 +00:00
Introduces the Sessions feature: a workspace where the AI chat and an editor (flow / script / app / raw-app) sit side-by-side, with each session having its own AIChatManager instance, history, and target item. Sessions are persisted across reloads and can be staged into forks for review. Key pieces: - sessions/ — SessionWrapper (the split-pane shell), SessionPicker (sidebar list), SessionForkBar, SessionWorkspaceBar, FlowEditorView / ScriptEditorView / AppEditorView / RawAppEditorView, ForkDiffDrawer, sessionRuntime (per-session AIChatManager + draft state), sessionState (in-memory + persisted index), sessionUnread, sessionScope, appDraftCodec / flowDraftCodec, forkEditUrl, /sessions route. - WorkspaceItemDrillPicker refactor — extracts WorkspaceItemRow + adds surfaceAI drafts, stale-while-revalidate. workspacePicker.ts drops explicit invalidate() in favor of always re-fetching in the background. - ForkDiffDrawer + WorkspaceItemDiffViewer — per-kind diff bodies reusable from the compare page. FlowGraphDiffViewer / FlowGraphV2 gain inlineDiff forwarding + onHeight callback for equal-height layout. - Global AI chat sessions plumbing — AIChatManager exports the class + adds disabledModes, beforeSend hook, scoped instance context. AIChat / AIChatDisplay accept session-only props (wideLayout, emptyHint, inputPreface, hideHeader, hideModeSelector, forceDisabled). Chat preserved across /flows/add → /flows/edit, /scripts/add → /scripts/edit. - Draft-first loaders — sessions open drafts when present, otherwise seed a draft from the last deployed value via globalDraftStore. RawAppEditor / AppEditor / AppEditorHeaderDeploy get newApp prop + fixes so draft-only apps can deploy. - Compare page (/forks/compare) — bigger overhaul to plug into the new drawer. - Sidebar — Sessions entry + unread badge + status dot in SidebarContent / MenuButton / SideBarNotification. - Misc fixes — chat group color palette constraint, deploy_workspace_item confirmation dropped, open_preview tool, picker drafts surfacing, fork archive/delete buttons on compare page. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
153 lines
4.6 KiB
Svelte
153 lines
4.6 KiB
Svelte
<!--
|
|
@component
|
|
Inline-editable text. Renders as a static button in idle mode; clicking it
|
|
swaps to a `TextInput` whose width tracks the content (no layout shift on
|
|
toggle). `Enter` or `blur` commits via `onSave`; `Escape` discards.
|
|
|
|
Use it for header titles, summaries, list-item names — anywhere the user
|
|
should be able to edit a label in place without opening a modal or popover.
|
|
|
|
```svelte
|
|
<EditableInput
|
|
value={summary}
|
|
placeholder="Add a summary..."
|
|
onSave={(v) => (summary = v)}
|
|
textClass="text-xs font-semibold text-emphasis"
|
|
/>
|
|
```
|
|
|
|
The current value isn't bound — `onSave` is fired with the trimmed draft
|
|
whenever it differs from the prior `value`, including with `''` when the
|
|
user clears the field. Callers that want to reject empty commits should
|
|
guard inside their `onSave` handler. The parent owns the canonical state;
|
|
this component just proposes new values.
|
|
-->
|
|
<script lang="ts">
|
|
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
|
|
|
interface Props {
|
|
/** Current value displayed in idle mode and pre-filled when entering edit mode. */
|
|
value: string
|
|
/** Shown when `value` is empty, in both idle and editing modes. */
|
|
placeholder?: string
|
|
/**
|
|
* Called when the user commits a changed value (Enter or blur). Fires
|
|
* with the trimmed draft, including `''` if the user cleared the field.
|
|
* Not called on Escape, or on blur when the trimmed draft matches the
|
|
* prior `value`. Guard against empty in your handler if needed.
|
|
*/
|
|
onSave?: (newValue: string) => void
|
|
/** When false, the component renders as plain text (not clickable). Default true. */
|
|
editable?: boolean
|
|
/** TextInput size in editing mode. Idle mode is unaffected (text only). */
|
|
size?: 'xs' | 'sm' | 'md' | 'lg'
|
|
/** Wrapper classes. Use for layout (margin, max-width, alignment) only — not text styling. */
|
|
class?: string
|
|
/** Extra classes on the inner `<input>` in editing mode. Background/border/shadow are reset on top of these. */
|
|
inputClass?: string
|
|
/**
|
|
* Text styling (font-size, weight, color, line-height...) applied to *both*
|
|
* the idle button and the editing input so the two render identically and
|
|
* the toggle doesn't visually shift.
|
|
*/
|
|
textClass?: string
|
|
}
|
|
|
|
let {
|
|
value,
|
|
placeholder = '',
|
|
onSave,
|
|
editable = true,
|
|
size = 'sm',
|
|
class: className = '',
|
|
inputClass = '',
|
|
textClass = ''
|
|
}: Props = $props()
|
|
|
|
let editing = $state(false)
|
|
let draft = $state('')
|
|
let textInputComponent: TextInput | undefined = $state(undefined)
|
|
|
|
function startEditing() {
|
|
if (!editable) return
|
|
editing = true
|
|
draft = value ?? ''
|
|
requestAnimationFrame(() => {
|
|
textInputComponent?.focus()
|
|
textInputComponent?.select()
|
|
})
|
|
}
|
|
|
|
// External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap
|
|
// stays active for a brief window after the menu closes — focusing our
|
|
// input during that window causes checkFocusIn to slam focus back out, which
|
|
// fires onblur=save and instantly closes the edit. A 50ms defer is enough
|
|
// for Melt's trap to release.
|
|
export function edit() {
|
|
setTimeout(startEditing, 50)
|
|
}
|
|
|
|
function save() {
|
|
// Re-entry guard: Enter calls `save()` and sets `editing = false`,
|
|
// which unmounts the `<input>` and synchronously fires its `blur`
|
|
// handler — also `save()`. Without this guard, `onSave` would fire
|
|
// twice for the same edit.
|
|
if (!editing) return
|
|
editing = false
|
|
const trimmed = draft.trim()
|
|
if (trimmed !== (value ?? '')) {
|
|
onSave?.(trimmed)
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') save()
|
|
else if (e.key === 'Escape') editing = false
|
|
}
|
|
</script>
|
|
|
|
{#if editing}
|
|
<span
|
|
class="input-sizer inline-grid items-center {textClass} {className}"
|
|
data-value={draft || placeholder}
|
|
>
|
|
<TextInput
|
|
bind:this={textInputComponent}
|
|
bind:value={draft}
|
|
{size}
|
|
class="!bg-transparent !border-0 !shadow-none -p-0 !m-0 !min-w-0 !min-h-0 !h-auto {textClass} {inputClass}"
|
|
inputProps={{
|
|
placeholder,
|
|
onblur: save,
|
|
onkeydown: handleKeydown,
|
|
spellcheck: false,
|
|
size: 1,
|
|
style: 'padding: 2px !important; grid-area: 1 / 1'
|
|
}}
|
|
/>
|
|
</span>
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
onclick={startEditing}
|
|
disabled={!editable}
|
|
aria-label={editable ? `Edit ${placeholder.toLowerCase() || 'value'}` : undefined}
|
|
class="text-left truncate rounded p-0.5 {editable
|
|
? 'cursor-text hover:bg-surface-hover'
|
|
: 'cursor-default'} {textClass} {className}"
|
|
>
|
|
{value || placeholder}
|
|
</button>
|
|
{/if}
|
|
|
|
<style>
|
|
.input-sizer::after {
|
|
content: attr(data-value) ' ';
|
|
visibility: hidden;
|
|
white-space: pre;
|
|
grid-area: 1 / 1;
|
|
font: inherit;
|
|
padding: 2px;
|
|
}
|
|
</style>
|