Compare commits

...

17 Commits

Author SHA1 Message Date
Austin Mroz
85d66f84d2 Fix selection indicator displaying outside builder 2026-02-26 15:41:41 -08:00
Austin Mroz
d6eade60b6 Check value of ref 2026-02-26 14:58:15 -08:00
Austin Mroz
6eb478dc0f Merge pysssss/app-builder-preview 2026-02-26 14:23:15 -08:00
Austin Mroz
05f866335b Revert "Show inputs during preview step"
Separate PR was opened which was cleaner
2026-02-26 14:11:19 -08:00
Austin Mroz
c71e8b9c5e Design nits
- Shorter tooltip delay
- Never show sidebar in builder mode
- Prefer widget name over node name
- Non-breaking space in example node names
- Remove node background colors
2026-02-26 12:49:12 -08:00
pythongosssss
f3120168ef - render app builder in arrange mode
- add orderable widget list
- fix splitter layout issues
2026-02-26 12:41:26 -08:00
Austin Mroz
6acb3a1e49 Show inputs with re-ordering during preview step 2026-02-26 12:41:17 -08:00
Austin Mroz
6c4c9bf58e Support displaying nodes not in the root graph 2026-02-26 12:41:14 -08:00
Austin Mroz
afb2721ef9 Further input indicator iteration 2026-02-26 12:41:11 -08:00
Austin Mroz
4cacdec310 Can toggle selection by outside corner 2026-02-26 12:41:08 -08:00
Austin Mroz
b1c10073f9 Add vue app output selection support 2026-02-26 12:41:05 -08:00
Austin Mroz
297fde5934 Remove subgraphId tracking from widgetValueStore 2026-02-26 12:41:02 -08:00
Austin Mroz
4669c922bc Store inputs by locator id.
Mixed feelings here.
2026-02-26 12:40:59 -08:00
Austin Mroz
1ac110024f Minimal widget promotion 2026-02-26 12:40:56 -08:00
Austin Mroz
a257df9875 Context handler on widgets 2026-02-26 12:40:50 -08:00
pythongosssss
9fb93a5b0a App mode - more updates & fixes (#9137)
## Summary

- fix sizing of sidebars in app mode
- update feedback button to match design
- update job queue notification
- clickable queue spinner item to allow clear queue
- refactor mode out of store to specific workflow instance
- support different saved vs active mode
- other styling/layout tweaks

## Changes

- **What**: Changes the store to a composable and moves the mode state
to the workflow.
- This enables switching between tabs and maintaining the mode they were
in

## Screenshots (if applicable)
<img width="1866" height="1455" alt="image"
src="https://github.com/user-attachments/assets/f9a8cd36-181f-4948-b48c-dd27bd9127cf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9137-App-mode-more-updates-fixes-3106d73d365081a18ccff6ffe24fdec7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-26 09:55:10 -08:00
Benjamin Lu
ac12a3d9b9 fix: preserve refill date slashes in subscription credits label (#9251)
### Motivation
- Subscription credit labels were rendering the refill date with
HTML-escaped separators (`&#x2F;`) because `vue-i18n` parameter escaping
was applied to the date interpolation.
- The goal is to render date-only parameters like `MM/DD/YY` with
literal slashes so the UI shows a human-readable date string.

### Description
- Disabled `vue-i18n` parameter escaping for the
`subscription.creditsRemainingThisMonth` and
`subscription.creditsRemainingThisYear` lookups in both subscription
panels by passing `{ escapeParameter: false }` to `t()` in
`SubscriptionPanelContentLegacy.vue` and
`SubscriptionPanelContentWorkspace.vue`.
- Adjusted the unit test i18n setup in `SubscriptionPanel.test.ts` to
include `escapeParameter: true` in the test `i18n` instance and updated
the test messages to use `Included (Refills {date})`.
- Added a regression unit test in `SubscriptionPanel.test.ts` asserting
the rendered label contains `Included (Refills 12/31/24)` and does not
contain the escaped entity `&#x2F;`.

### Testing
- Ran formatting with `pnpm format` which completed successfully.
- Ran lint via `pnpm lint` which passed with pre-existing warnings only
(no new errors).
- Ran type checking with `pnpm typecheck` (via `vue-tsc --noEmit`) which
completed successfully.
- Ran the modified unit tests with `pnpm vitest run
src/platform/cloud/subscription/components/SubscriptionPanel.test.ts`
and the test file passed (10 passed, 5 skipped).
- Attempted a Playwright-based visual capture of the running app but
Chromium crashed in this environment (SIGSEGV) before navigation, so no
screenshot was produced.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_69a0175f58788330b2256329a500e14b)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9251-fix-preserve-refill-date-slashes-in-subscription-credits-label-3136d73d36508182b770f5719a52d189)
by [Unito](https://www.unito.io)
2026-02-26 09:37:03 -08:00
48 changed files with 1116 additions and 337 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -18,7 +18,7 @@
<Splitter
:key="splitterRefreshKey"
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
:state-key="sidebarStateKey"
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
state-storage="local"
@resizestart="onResizestart"
>
@@ -35,8 +35,10 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'left' ? 10 : 15"
:size="20"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
@@ -54,7 +56,7 @@
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="80" class="flex flex-col">
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
@@ -95,8 +97,10 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'right' ? 10 : 15"
:size="20"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
@@ -123,8 +127,14 @@ import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -145,15 +155,17 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const appModeStore = useAppModeStore()
const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
() => rightSidePanelVisible.value || isSelectMode.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const sidebarPanelVisible = computed(
() => activeSidebarTab.value !== null && !isBuilderMode.value
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value
@@ -174,7 +186,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {

View File

@@ -8,12 +8,12 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { enableAppBuilder, setMode } = useAppMode()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
@@ -24,7 +24,7 @@ const isWorkflowsActive = computed(
)
function enterBuilderMode() {
appModeStore.setMode('builder:select')
setMode('builder:select')
}
function openAssets() {
@@ -61,7 +61,7 @@ function openTemplates() {
</WorkflowActionsDropdown>
<Button
v-if="appModeStore.enableAppBuilder"
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, ref, toValue } from 'vue'
import { computed, provide, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
@@ -23,8 +24,11 @@ import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -36,13 +40,27 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isArrangeMode } = useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return null
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
const node = getNodeByLocatorId(app.rootGraph, String(nodeId))
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return { nodeId, widgetName }
@@ -179,17 +197,42 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{ t('linearMode.builder.title') }}
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
<Button class="ml-auto" @click="appModeStore.exitBuilder">
{{ t('linearMode.builder.exit') }}
</Button>
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'p-2 my-2 pointer-events-auto')"
:aria-label="`${widget.label ?? widgetName} ${node.title}`"
>
<div class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-else
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -232,10 +275,12 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="!isArrangeMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -274,7 +319,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</DraggableList>
</PropertiesAccordionItem>
<Teleport to="body">
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
to="body"
>
<div
:class="
cn(
@@ -308,13 +356,19 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k === key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
</div>

View File

@@ -20,7 +20,7 @@
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="appModeStore.setMode(step.id)"
@click="setMode(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
@@ -31,9 +31,9 @@
<!-- Save -->
<ConnectOutputPopover
v-if="!appModeStore.hasOutputs"
v-if="!hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="appModeStore.setMode('builder:select')"
@switch="setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
@@ -50,7 +50,7 @@
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="appModeStore.setBuilderSaving(true)"
@click="setSaving(true)"
>
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
@@ -62,31 +62,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useEventListener } from '@vueuse/core'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { useBuilderSave } from './useBuilderSave'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { mode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const { saving, setSaving } = useBuilderSave()
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
void appModeStore.exitBuilder()
})
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'

View File

@@ -33,8 +33,11 @@ const entries = computed(() => {
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto" v-text="title" />
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
<span class="mr-auto truncate shrink-1" v-text="title" />
<span
class="text-muted-foreground mr-2 text-end truncate shrink-3"
v-text="subTitle"
/>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">

View File

@@ -1,30 +1,36 @@
import { watch } from 'vue'
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
import { whenever } from '@vueuse/core'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { toastErrorHandler } = useErrorHandling()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
watch(
() => appModeStore.isBuilderSaving,
(saving) => {
if (saving) void onBuilderSave()
}
)
const saving = ref(false)
whenever(saving, onBuilderSave)
function setSaving(value: boolean) {
saving.value = value
}
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
@@ -33,13 +39,14 @@ export function useBuilderSave() {
return
}
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
if (!workflow.isTemporary && workflow.initialMode != null) {
// Re-save with the previously chosen mode — no dialog needed.
try {
workflow.changeTracker?.checkState()
appModeStore.saveSelectedToWorkflow()
appModeStore.flushSelections()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
} catch (e) {
toastErrorHandler(e)
resetSaving()
}
return
@@ -73,17 +80,19 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.saveSelectedToWorkflow()
appModeStore.flushSelections()
const mode = openAsApp ? 'app' : 'graph'
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp
initialMode: mode
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch {
} catch (e) {
toastErrorHandler(e)
closeSaveDialog()
resetSaving()
}
@@ -97,7 +106,7 @@ export function useBuilderSave() {
workflowName,
savedAsApp,
onViewApp: () => {
appModeStore.setMode('app')
setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
@@ -118,6 +127,8 @@ export function useBuilderSave() {
}
function resetSaving() {
appModeStore.setBuilderSaving(false)
saving.value = false
}
return { saving, setSaving }
}

View File

@@ -21,7 +21,7 @@
</div>
</div>
</template>
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI" #side-bar-panel>
@@ -31,27 +31,24 @@
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
<template v-if="showUI && !isBuilderMode" #topmenu>
<TopMenuSection />
</template>
<template v-if="showUI" #bottom-panel>
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
v-if="canvasMenuEnabled && !isBuilderMode"
class="pointer-events-auto"
/>
<MiniMap
v-if="
comfyAppReady &&
minimapEnabled &&
betaMenuEnabled &&
!appModeStore.isBuilderMode
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
"
class="pointer-events-auto"
/>
@@ -127,10 +124,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
@@ -184,7 +181,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -205,7 +202,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { mode, isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

View File

@@ -11,6 +11,7 @@ const {
enableEmptyState,
tooltip,
size = 'default',
tooltipDelay = 1000,
class: className
} = defineProps<{
disabled?: boolean
@@ -18,6 +19,7 @@ const {
enableEmptyState?: boolean
tooltip?: string
size?: 'default' | 'lg'
tooltipDelay?: number
class?: string
}>()
@@ -27,7 +29,7 @@ const isExpanded = computed(() => !isCollapse.value && !disabled)
const tooltipConfig = computed(() => {
if (!tooltip) return undefined
return { value: tooltip, showDelay: 1000 }
return { value: tooltip, showDelay: tooltipDelay }
})
</script>

View File

@@ -16,10 +16,7 @@ import {
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useWidgetValueStore,
stripGraphPrefix
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -34,6 +31,7 @@ const {
node,
isDraggable = false,
hiddenFavoriteIndicator = false,
hiddenWidgetActions = false,
showNodeName = false,
parents = [],
isShownOnParents = false
@@ -42,6 +40,7 @@ const {
node: LGraphNode
isDraggable?: boolean
hiddenFavoriteIndicator?: boolean
hiddenWidgetActions?: boolean
showNodeName?: boolean
parents?: SubgraphNode[]
isShownOnParents?: boolean
@@ -73,7 +72,7 @@ function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const bareNodeId = String(sourceNode.id)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
@@ -170,7 +169,10 @@ const displayLabel = customRef((track, trigger) => {
>
{{ sourceNodeName }}
</span>
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
<div
v-if="!hiddenWidgetActions"
class="flex items-center gap-1 shrink-0 pointer-events-auto"
>
<WidgetActions
v-model:label="displayLabel"
:widget="widget"

View File

@@ -3,7 +3,7 @@
ref="containerRef"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
props.class
)
"

View File

@@ -10,7 +10,7 @@
@click="handleClick"
>
<i
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
v-if="workflowOption.workflow.initialMode === 'app'"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span

View File

@@ -25,15 +25,19 @@ whenever(feedbackRef, () => {
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
<i class="icon-[lucide--circle-help] size-4" />
</Button>
<Popover v-else>
<template #button>
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<i class="icon-[lucide--circle-question-mark] size-6" />
<Button
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
</template>
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />

View File

@@ -222,13 +222,9 @@ function safeWidgetMapper(
read_only: widget.options.read_only
}
: undefined
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const localId = isPromotedWidgetView(widget)
const nodeId = isPromotedWidgetView(widget)
? widget.sourceNodeId
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name

View File

@@ -1,7 +1,12 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
LGraphGroup,
LGraphNode,
NodeId
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
import { isLGraphGroup } from '@/utils/litegraphUtil'
@@ -46,7 +51,7 @@ export enum BadgeVariant {
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
const hoveredWidgetName = ref<string>()
const hoveredWidget = ref<[string, NodeId | undefined]>()
/**
* Toggle the node options popover
@@ -63,14 +68,12 @@ export function toggleNodeOptions(event: Event) {
* Use this for contextmenu events where we always want to show at the new position
* @param event - The trigger event (must be MouseEvent for position)
*/
export function showNodeOptions(event: MouseEvent) {
hoveredWidgetName.value = undefined
const target = event.target
if (target instanceof HTMLElement) {
const widgetEl = target.closest('.lg-node-widget')
if (widgetEl instanceof HTMLElement)
hoveredWidgetName.value = widgetEl.dataset.widgetName
}
export function showNodeOptions(
event: MouseEvent,
widgetName?: string,
nodeId?: NodeId
) {
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
}
@@ -259,8 +262,16 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const rawName = hoveredWidgetName.value
const widget = node?.widgets?.find((w) => w.name === rawName)
const [widgetName, nodeId] = hoveredWidget.value ?? []
const widget =
nodeId !== undefined
? node?.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
: node?.widgets?.find((w) => w.name === widgetName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)

View File

@@ -0,0 +1,47 @@
import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
)
const isSelectMode = computed(() => mode.value === 'builder:select')
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || mode.value === 'builder:select'
)
function setMode(newMode: AppMode) {
if (newMode === mode.value) return
const workflow = workflowStore.activeWorkflow
if (workflow) workflow.activeMode = newMode
}
return {
mode,
enableAppBuilder,
isBuilderMode,
isSelectMode,
isArrangeMode,
isAppMode,
isGraphMode,
setMode
}
}

View File

@@ -1338,8 +1338,6 @@ export function useCoreCommands(): ComfyCommand[] {
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
const newMode = !canvasStore.linearMode
if (newMode) useTelemetry()?.trackEnterLinear({ source })
app.rootGraph.extra.linearMode = newMode
workflowStore.activeWorkflow?.changeTracker?.checkState()
canvasStore.linearMode = newMode
}
}

View File

@@ -0,0 +1,11 @@
/** Default panel size (%) for sidebar and builder panels */
export const SIDE_PANEL_SIZE = 20
/** Default panel size (%) for the center/main panel */
export const CENTER_PANEL_SIZE = 80
/** Minimum panel size (%) for the sidebar */
export const SIDEBAR_MIN_SIZE = 10
/** Minimum panel size (%) for the builder panel */
export const BUILDER_MIN_SIZE = 15

View File

@@ -9,10 +9,7 @@ import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -83,7 +80,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
this.bareNodeId = stripGraphPrefix(nodeId)
this.bareNodeId = nodeId
}
get node(): SubgraphNode {

View File

@@ -1159,8 +1159,8 @@
"queue": {
"initializingAlmostReady": "Initializing - Almost ready",
"inQueue": "In queue...",
"jobAddedToQueue": "Job added to queue",
"jobQueueing": "Job queueing",
"jobAddedToQueue": "Job queued",
"jobQueueing": "Job queuing",
"completedIn": "Finished in {duration}",
"jobMenu": {
"openAsWorkflowNewTab": "Open as workflow in new tab",
@@ -2996,7 +2996,8 @@
},
"linearMode": {
"linearMode": "App Mode",
"beta": "App Mode in Beta - Feedback",
"beta": "App mode in beta",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"runCount": "Number of runs",
@@ -3034,11 +3035,15 @@
"promptAddInputs": "Click on node parameters to add them here as inputs",
"noInputs": "No inputs added yet",
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
"inputsExample": "Examples: “Load image”, “Text prompt”, “Steps”",
"inputsExample": "Examples: \u201cLoad\u00a0image\u201d, \u201cText\u00a0prompt\u201d, \u201cSteps\u201d",
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
"noOutputs": "No output nodes added yet",
"outputsDesc": "Connect at least one output node so users can see results after running.",
"outputsExample": "Examples: “Save Image” or “Save Video”"
"outputsExample": "Examples: \u201cSave\u00a0Image\u201d or \u201cSave\u00a0Video\u201d"
},
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"
}
},
"missingNodes": {

View File

@@ -101,6 +101,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
const i18n = createI18n({
legacy: false,
locale: 'en',
escapeParameter: true,
messages: {
en: {
subscription: {
@@ -112,7 +113,8 @@ const i18n = createI18n({
partnerNodesBalance: 'Partner Nodes Balance',
partnerNodesDescription: 'Credits for partner nodes',
totalCredits: 'Total Credits',
creditsRemainingThisMonth: 'Credits remaining this month',
creditsRemainingThisMonth: 'Included (Refills {date})',
creditsRemainingThisYear: 'Included (Refills {date})',
creditsYouveAdded: "Credits you've added",
monthlyBonusDescription: 'Monthly bonus',
prepaidDescription: 'Prepaid credits',
@@ -286,6 +288,13 @@ describe('SubscriptionPanel', () => {
const wrapper = createWrapper()
expect(wrapper.findAll('.skeleton').length).toBe(0)
})
it('renders refill date with literal slashes', () => {
mockIsActiveSubscription.value = true
const wrapper = createWrapper()
expect(wrapper.text()).toContain('Included (Refills 12/31/24)')
expect(wrapper.text()).not.toContain('&#x2F;')
})
})
// TODO: Re-enable when migrating to VTL so we can find by user visible content.

View File

@@ -256,12 +256,24 @@ const refillsDate = computed(() => {
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t('subscription.creditsRemainingThisYear', {
date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
? t(
'subscription.creditsRemainingThisYear',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
: t(
'subscription.creditsRemainingThisMonth',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
)
const planTotalCredits = computed(() => {

View File

@@ -2,12 +2,58 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
import type {
LoadedComfyWorkflow,
PendingWarnings
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
function createModeTestWorkflow(
options: {
path?: string
initialMode?: AppMode | null
activeMode?: AppMode | null
loaded?: boolean
} = {}
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflowClass({
path: options.path ?? 'workflows/test.json',
modified: Date.now(),
size: 100
})
if ('initialMode' in options) workflow.initialMode = options.initialMode
workflow.activeMode = options.activeMode ?? null
if (options.loaded !== false) {
workflow.changeTracker = createMockChangeTracker()
workflow.content = '{}'
workflow.originalContent = '{}'
}
return workflow as LoadedComfyWorkflow
}
function makeWorkflowData(
extra: Record<string, unknown> = {}
): ComfyWorkflowJSON {
return {
last_node_id: 5,
last_link_id: 3,
nodes: [],
links: [],
groups: [],
config: {},
version: 0.4,
extra
}
}
const { mockShowMissingNodes, mockShowMissingModels } = vi.hoisted(() => ({
mockShowMissingNodes: vi.fn(),
@@ -72,6 +118,14 @@ vi.mock('@/stores/domWidgetStore', () => ({
})
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
get workflow() {
return useWorkflowStore()
}
})
}))
const MISSING_MODELS: PendingWarnings['missingModels'] = {
missingModels: [
{ name: 'model.safetensors', url: '', directory: 'checkpoints' }
@@ -83,7 +137,7 @@ function createWorkflow(
warnings: PendingWarnings | null = null,
options: { loadable?: boolean; path?: string } = {}
): ComfyWorkflow {
return {
const wf = {
pendingWarnings: warnings,
...(options.loadable && {
path: options.path ?? 'workflows/test.json',
@@ -91,7 +145,8 @@ function createWorkflow(
activeState: { nodes: [], links: [] },
changeTracker: { reset: vi.fn(), restore: vi.fn() }
})
} as Partial<ComfyWorkflow> as ComfyWorkflow
} as Partial<ComfyWorkflow>
return wf as ComfyWorkflow
}
function enableWarningSettings() {
@@ -180,12 +235,7 @@ describe('useWorkflowService', () => {
workflowStore = useWorkflowStore()
vi.mocked(app.loadGraphData).mockImplementation(
async (_data, _clean, _restore, wf) => {
;(
workflowStore as Partial<Record<string, unknown>> as Record<
string,
unknown
>
).activeWorkflow = wf
workflowStore.activeWorkflow = wf as LoadedComfyWorkflow
}
)
})
@@ -256,4 +306,231 @@ describe('useWorkflowService', () => {
expect(mockShowMissingNodes).toHaveBeenCalledTimes(1)
})
})
describe('per-workflow mode switching', () => {
let appMode: ReturnType<typeof useAppMode>
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
function mockOpenWorkflow() {
vi.spyOn(workflowStore, 'openWorkflow').mockImplementation(async (wf) => {
// Simulate load() setting changeTracker on first open
if (!wf.changeTracker) {
wf.changeTracker = createMockChangeTracker()
wf.content = '{}'
wf.originalContent = '{}'
}
const loaded = wf as LoadedComfyWorkflow
workflowStore.activeWorkflow = loaded
return loaded
})
}
beforeEach(() => {
appMode = useAppMode()
workflowStore = useWorkflowStore()
service = useWorkflowService()
})
describe('mode derivation from active workflow', () => {
it('reflects initialMode of the active workflow', () => {
const workflow = createModeTestWorkflow({ initialMode: 'app' })
workflowStore.activeWorkflow = workflow
expect(appMode.mode.value).toBe('app')
})
it('activeMode takes precedence over initialMode', () => {
const workflow = createModeTestWorkflow({
initialMode: 'app',
activeMode: 'graph'
})
workflowStore.activeWorkflow = workflow
expect(appMode.mode.value).toBe('graph')
})
it('defaults to graph when no active workflow', () => {
expect(appMode.mode.value).toBe('graph')
})
it('updates when activeWorkflow changes', () => {
const workflow1 = createModeTestWorkflow({
path: 'workflows/one.json',
initialMode: 'app'
})
const workflow2 = createModeTestWorkflow({
path: 'workflows/two.json',
activeMode: 'builder:select'
})
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('app')
workflowStore.activeWorkflow = workflow2
expect(appMode.mode.value).toBe('builder:select')
})
})
describe('setMode writes to active workflow', () => {
it('writes activeMode without changing initialMode', () => {
const workflow = createModeTestWorkflow({ initialMode: 'graph' })
workflowStore.activeWorkflow = workflow
appMode.setMode('builder:arrange')
expect(workflow.activeMode).toBe('builder:arrange')
expect(workflow.initialMode).toBe('graph')
expect(appMode.mode.value).toBe('builder:arrange')
})
})
describe('afterLoadNewGraph initializes initialMode', () => {
beforeEach(() => {
mockOpenWorkflow()
})
it('sets initialMode from extra.linearMode on first load', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true })
)
expect(workflow.initialMode).toBe('app')
})
it('leaves initialMode null when extra.linearMode is absent', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(workflow, makeWorkflowData())
expect(workflow.initialMode).toBeNull()
})
it('sets initialMode to graph when extra.linearMode is false', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: false })
)
expect(workflow.initialMode).toBe('graph')
})
it('does not set initialMode on tab switch even if data has linearMode', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
// First load — no linearMode in data
await service.afterLoadNewGraph(workflow, makeWorkflowData())
expect(workflow.initialMode).toBeNull()
// User switches to app mode at runtime
workflow.activeMode = 'app'
// Tab switch / reload — data now has linearMode (leaked from graph)
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true })
)
// initialMode should NOT have been updated — only builder save sets it
expect(workflow.initialMode).toBeNull()
})
it('preserves existing initialMode on tab switch', async () => {
const workflow = createModeTestWorkflow({
initialMode: 'app'
})
await service.afterLoadNewGraph(workflow, makeWorkflowData())
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to app for fresh string-based loads with linearMode', async () => {
vi.spyOn(workflowStore, 'createNewTemporary').mockReturnValue(
createModeTestWorkflow()
)
await service.afterLoadNewGraph(
'test.json',
makeWorkflowData({ linearMode: true })
)
expect(appMode.mode.value).toBe('app')
})
it('syncs linearMode to rootGraph.extra for draft persistence', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true })
)
expect(app.rootGraph.extra.linearMode).toBe(true)
})
it('reads initialMode from file when draft lacks linearMode (restoration)', async () => {
const filePath = 'workflows/saved-app.json'
const fileInitialState = makeWorkflowData({ linearMode: true })
const mockTracker = createMockChangeTracker()
mockTracker.initialState = fileInitialState
// Persisted, not-loaded workflow in the store
const persistedWorkflow = new ComfyWorkflowClass({
path: filePath,
modified: Date.now(),
size: 100
})
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(
persistedWorkflow
)
vi.spyOn(workflowStore, 'openWorkflow').mockImplementation(
async (wf) => {
wf.changeTracker = mockTracker
wf.content = JSON.stringify(fileInitialState)
wf.originalContent = wf.content
workflowStore.activeWorkflow = wf as LoadedComfyWorkflow
return wf as LoadedComfyWorkflow
}
)
// Draft data has NO linearMode (simulates rootGraph serialization)
const draftData = makeWorkflowData()
await service.afterLoadNewGraph('saved-app.json', draftData)
// initialMode should come from the file, not the draft
expect(persistedWorkflow.initialMode).toBe('app')
expect(app.rootGraph.extra.linearMode).toBe(true)
})
})
describe('round-trip mode preservation', () => {
it('each workflow retains its own mode across tab switches', () => {
const workflow1 = createModeTestWorkflow({
path: 'workflows/one.json',
activeMode: 'builder:select'
})
const workflow2 = createModeTestWorkflow({
path: 'workflows/two.json',
initialMode: 'app'
})
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('builder:select')
workflowStore.activeWorkflow = workflow2
expect(appMode.mode.value).toBe('app')
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('builder:select')
})
})
})
})

View File

@@ -7,24 +7,31 @@ import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { syncLinearMode } from '@/platform/workflow/management/stores/comfyWorkflow'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog'
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt } from '@/utils/formatUtil'
function linearModeToAppMode(linearMode: unknown): AppMode | null {
if (typeof linearMode !== 'boolean') return null
return linearMode ? 'app' : 'graph'
}
export const useWorkflowService = () => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
@@ -93,24 +100,20 @@ export const useWorkflowService = () => {
* Save a workflow as a new file
* @param workflow The workflow to save
* @param options.filename Pre-supplied filename (skips the prompt dialog)
* @param options.openAsApp If set, updates linearMode extra before saving
*/
const saveWorkflowAs = async (
workflow: ComfyWorkflow,
options: { filename?: string; openAsApp?: boolean } = {}
options: { filename?: string; initialMode?: AppMode } = {}
): Promise<boolean> => {
const newFilename = options.filename ?? (await workflow.promptSave())
if (!newFilename) return false
if (options.openAsApp !== undefined) {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearMode = options.openAsApp
workflow.changeTracker?.checkState()
}
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
const isSelfOverwrite =
existingWorkflow?.path === workflow.path && !existingWorkflow?.isTemporary
if (existingWorkflow && !existingWorkflow.isTemporary) {
const res = await dialogService.confirm({
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
@@ -121,15 +124,20 @@ export const useWorkflowService = () => {
if (res !== true) return false
if (existingWorkflow.path === workflow.path) {
await saveWorkflow(workflow)
return true
if (!isSelfOverwrite) {
const deleted = await deleteWorkflow(existingWorkflow, true)
if (!deleted) return false
}
const deleted = await deleteWorkflow(existingWorkflow, true)
if (!deleted) return false
}
if (workflow.isTemporary) {
if (options.initialMode) workflow.initialMode = options.initialMode
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
workflow.changeTracker?.checkState()
if (isSelfOverwrite) {
await saveWorkflow(workflow)
} else if (workflow.isTemporary) {
await renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
@@ -148,6 +156,8 @@ export const useWorkflowService = () => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
workflow.changeTracker?.checkState()
await workflowStore.saveWorkflow(workflow)
}
}
@@ -358,13 +368,17 @@ export const useWorkflowService = () => {
workflowData: ComfyWorkflowJSON
) => {
const workflowStore = useWorkspaceStore().workflow
if (
workflowData.extra?.linearMode !== undefined ||
!workflowData.nodes.length
) {
if (workflowData.extra?.linearMode && !useCanvasStore().linearMode)
const { isAppMode } = useAppMode()
const wasAppMode = isAppMode.value
// Determine the initial app mode for fresh loads from serialized state.
// null means linearMode was never explicitly set (not builder-saved).
const freshLoadMode = linearModeToAppMode(workflowData.extra?.linearMode)
function trackIfEnteringApp(workflow: ComfyWorkflow) {
if (!wasAppMode && workflow.initialMode === 'app') {
useTelemetry()?.trackEnterLinear({ source: 'workflow' })
useCanvasStore().linearMode = !!workflowData.extra?.linearMode
}
}
if (value === null || typeof value === 'string') {
@@ -381,26 +395,39 @@ export const useWorkflowService = () => {
if (existingWorkflow?.isPersisted && !existingWorkflow.isLoaded) {
const loadedWorkflow =
await workflowStore.openWorkflow(existingWorkflow)
if (loadedWorkflow.initialMode === undefined) {
// Prefer the file's linearMode over the draft's since the file
// is the authoritative saved state.
loadedWorkflow.initialMode =
linearModeToAppMode(
loadedWorkflow.initialState?.extra?.linearMode
) ?? freshLoadMode
trackIfEnteringApp(loadedWorkflow)
}
syncLinearMode(loadedWorkflow, [workflowData, app.rootGraph])
loadedWorkflow.changeTracker.reset(workflowData)
loadedWorkflow.changeTracker.restore()
return
}
}
if (useCanvasStore().linearMode) {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearMode = true
}
const tempWorkflow = workflowStore.createNewTemporary(
path ? appendJsonExt(path) : undefined,
workflowData
)
tempWorkflow.initialMode = freshLoadMode
trackIfEnteringApp(tempWorkflow)
syncLinearMode(tempWorkflow, [workflowData, app.rootGraph])
await workflowStore.openWorkflow(tempWorkflow)
return
}
const loadedWorkflow = await workflowStore.openWorkflow(value)
if (loadedWorkflow.initialMode === undefined) {
loadedWorkflow.initialMode = freshLoadMode
trackIfEnteringApp(loadedWorkflow)
}
syncLinearMode(loadedWorkflow, [workflowData, app.rootGraph])
loadedWorkflow.changeTracker.reset(workflowData)
loadedWorkflow.changeTracker.restore()
}

View File

@@ -2,6 +2,8 @@ import { markRaw } from 'vue'
import { t } from '@/i18n'
import type { ChangeTracker } from '@/scripts/changeTracker'
import type { AppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { UserFile } from '@/stores/userFileStore'
import type {
ComfyWorkflowJSON,
@@ -9,6 +11,11 @@ import type {
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingNodeType } from '@/types/comfy'
export interface LinearData {
inputs: [NodeId, string][]
outputs: NodeId[]
}
export interface PendingWarnings {
missingNodeTypes?: MissingNodeType[]
missingModels?: {
@@ -17,6 +24,31 @@ export interface PendingWarnings {
}
}
type LinearModeTarget = { extra?: Record<string, unknown> | null } | null
export function syncLinearMode(
workflow: ComfyWorkflow,
targets: LinearModeTarget[],
options?: { flushLinearData?: boolean }
): void {
for (const target of targets) {
if (!target) continue
if (workflow.initialMode === 'app' || workflow.initialMode === 'graph') {
const extra = (target.extra ??= {})
extra.linearMode = workflow.initialMode === 'app'
} else {
delete target.extra?.linearMode
}
if (options?.flushLinearData && workflow.dirtyLinearData) {
const extra = (target.extra ??= {})
extra.linearData = workflow.dirtyLinearData
}
}
if (options?.flushLinearData && workflow.dirtyLinearData) {
workflow.dirtyLinearData = null
}
}
export class ComfyWorkflow extends UserFile {
static readonly basePath: string = 'workflows/'
readonly tintCanvasBg?: string
@@ -33,6 +65,23 @@ export class ComfyWorkflow extends UserFile {
* Warnings deferred from load time, shown when the workflow is first focused.
*/
pendingWarnings: PendingWarnings | null = null
/**
* Initial app mode derived from the serialized workflow (extra.linearMode).
* - `undefined`: not yet resolved (first load hasn't happened)
* - `null`: resolved, but no mode was set (never builder-saved)
* - `AppMode`: resolved to a specific mode
*/
initialMode: AppMode | null | undefined = undefined
/**
* Current app mode set by the user during the session.
* Takes precedence over initialMode when present.
*/
activeMode: AppMode | null = null
/**
* In-progress builder selections not yet persisted via save.
* Preserved across tab switches, discarded on exitBuilder.
*/
dirtyLinearData: LinearData | null = null
/**
* @param options The path, modified, and size of the workflow.
@@ -129,6 +178,7 @@ export class ComfyWorkflow extends UserFile {
override unload(): void {
this.changeTracker = null
this.activeMode = null
super.unload()
}

View File

@@ -249,6 +249,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
modified: Date.now(),
size: -1
})
workflow.initialMode = existingWorkflow.initialMode
workflow.originalContent = workflow.content = JSON.stringify(state)
workflowLookup.value[workflow.path] = workflow
return workflow

View File

@@ -534,12 +534,24 @@ const refillsDate = computed(() => {
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t('subscription.creditsRemainingThisYear', {
date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
? t(
'subscription.creditsRemainingThisYear',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
: t(
'subscription.creditsRemainingThisMonth',
{
date: refillsDate.value
},
{
escapeParameter: false
}
)
)
const planTotalCredits = computed(() => {

View File

@@ -3,7 +3,7 @@ import { defineStore } from 'pinia'
import { computed, markRaw, ref, shallowRef } from 'vue'
import type { Raw } from 'vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type {
@@ -44,10 +44,11 @@ export const useCanvasStore = defineStore('canvas', () => {
// Reactive scale percentage that syncs with app.canvas.ds.scale
const appScalePercentage = ref(100)
const { isAppMode, setMode } = useAppMode()
const linearMode = computed({
get: () => useAppModeStore().isAppMode,
get: () => isAppMode.value,
set: (val: boolean) => {
useAppModeStore().setMode(val ? 'app' : 'graph')
setMode(val ? 'app' : 'graph')
}
})

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { mode } = useAppMode()
const appModeStore = useAppModeStore()
const { id, name } = defineProps<{ id: string; name: string }>()
const isPromoted = computed(() => appModeStore.selectedInputs.some(matchesThis))
function matchesThis([nodeId, widgetName]: [NodeId, string]) {
return id == nodeId && name === widgetName
}
function togglePromotion() {
if (isPromoted.value) remove(appModeStore.selectedInputs, matchesThis)
else appModeStore.selectedInputs.push([id, name])
}
</script>
<template>
<div
v-if="mode === 'builder:select'"
class="col-span-2 flex flex-row pointer-events-auto cursor-pointer gap-1 relative"
@pointerdown.capture.stop.prevent="togglePromotion"
@click.capture.stop.prevent
@pointerup.capture.stop.prevent
@pointermove.capture.stop.prevent
>
<div
:class="
cn(
'border-primary-background border rounded-sm size-4 self-center m-1',
isPromoted && 'bg-primary-background flex items-center'
)
"
>
<i
v-if="isPromoted"
class="icon-[lucide--check] bg-primary-foreground place-center"
/>
</div>
<div
:class="
cn(
'grid grid-cols-2 items-stretch ring-primary-background rounded-lg pointer-events-none flex-1',
isPromoted && 'ring-2'
)
"
>
<slot />
</div>
<div class="absolute size-full hover:bg-primary-background/10 rounded-lg" />
</div>
<slot v-else />
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { isSelectMode } = useAppMode()
const appModeStore = useAppModeStore()
const { id } = defineProps<{ id: string }>()
const isPromoted = computed(() =>
appModeStore.selectedOutputs.some(matchesThis)
)
function matchesThis(nodeId: NodeId) {
return id == nodeId
}
function togglePromotion() {
if (isPromoted.value) remove(appModeStore.selectedOutputs, matchesThis)
else appModeStore.selectedOutputs.push(id)
}
</script>
<template>
<div
v-if="isSelectMode"
:class="
cn(
'absolute w-full h-full pointer-events-auto ring-warning-background/50 ring-5 rounded-2xl',
isPromoted && 'ring-warning-background'
)
"
@click.capture.stop.prevent
@pointerup.capture.stop.prevent
@pointermove.capture.stop.prevent
@pointerdown.capture="togglePromotion"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isPromoted"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
/>
</div>
</div>
</template>

View File

@@ -1,15 +1,18 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
</script>
<template>
<div
v-if="appModeStore.hasOutputs"
v-if="hasOutputs"
role="article"
data-testid="arrange-preview"
class="flex flex-col items-center justify-center h-full w-3/4 gap-6 p-8 mx-auto"
@@ -47,11 +50,7 @@ const appModeStore = useAppModeStore()
<p class="mt-0 p-0">{{ t('linearMode.arrange.outputExamples') }}</p>
</div>
<div class="flex flex-row gap-2">
<Button
variant="primary"
size="lg"
@click="appModeStore.setMode('builder:select')"
>
<Button variant="primary" size="lg" @click="setMode('builder:select')">
{{ t('linearMode.arrange.switchToSelectButton') }}
</Button>
</div>

View File

@@ -16,7 +16,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -26,6 +25,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
@@ -35,7 +35,9 @@ const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
const { isActiveSubscription } = useBillingContext()
const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const props = defineProps<{
toastTo?: string | HTMLElement
@@ -66,7 +68,11 @@ const mappedSelections = computed(() => {
([id]) => id === nodeId
).map(([, widgetName]) => widgetName)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
const node = app.rootGraph.getNodeById(nodeId)
const node =
app.rootGraph.getNodeById(nodeId) ??
[...app.rootGraph.subgraphs.values()]
.flatMap((sg) => sg.nodes)
.find((n) => n.id == nodeId)
if (!node) continue
const nodeData = nodeToNodeData(node)
@@ -168,7 +174,7 @@ defineExpose({ runButtonClick })
</script>
<template>
<div
v-if="!appModeStore.isBuilderMode && appModeStore.hasOutputs"
v-if="!isBuilderMode && hasOutputs"
class="flex flex-col min-w-80 md:h-full"
>
<section
@@ -236,7 +242,6 @@ defineExpose({ runButtonClick })
/>
<NodeWidgets
:node-data
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg max-w-100"
/>
</template>
@@ -276,7 +281,6 @@ defineExpose({ runButtonClick })
'ring-2 ring-inset ring-node-stroke-error'
)
"
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
/>
</DropZone>
</template>
@@ -299,7 +303,7 @@ defineExpose({ runButtonClick })
<Button
v-else
variant="primary"
class="w-full mt-4"
class="w-full mt-4 text-sm"
size="lg"
@click="runButtonClick"
>
@@ -315,14 +319,18 @@ defineExpose({ runButtonClick })
:to="toastTo"
>
<div
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
class="bg-secondary-background text-base-foreground rounded-lg flex h-8 p-1 pr-2 gap-2 items-center"
>
<i
v-if="jobFinishedQueue"
class="icon-[lucide--check] size-5 bg-success-background"
class="icon-[lucide--check] size-5 text-muted-foreground"
/>
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
<span v-text="t('queue.jobAddedToQueue')" />
<span
v-text="
jobFinishedQueue ? t('queue.jobAddedToQueue') : t('queue.jobQueueing')
"
/>
</div>
</Teleport>
</template>

View File

@@ -27,13 +27,13 @@ import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const mediaActions = useMediaAssetActions()
const queueStore = useQueueStore()
const appModeStore = useAppModeStore()
const { mode: appModeValue } = useAppMode()
const { runButtonClick } = defineProps<{
runButtonClick?: (e: Event) => void
mobile?: boolean
@@ -165,7 +165,7 @@ async function rerun(e: Event) {
:model-url="selectedOutput!.url"
/>
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
<LinearArrange v-else-if="appModeStore.mode === 'builder:arrange'" />
<LinearArrange v-else-if="appModeValue === 'builder:arrange'" />
<LinearWelcome v-else />
<OutputHistory @update-selection="handleSelection" />
<OutputHistory class="not-md:mx-40" @update-selection="handleSelection" />
</template>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
</script>
<template>
@@ -24,7 +27,7 @@ const appModeStore = useAppModeStore()
<p class="mt-0">{{ t('linearMode.welcome.controls') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.sharing') }}</p>
</div>
<div v-if="appModeStore.hasOutputs" class="flex flex-row gap-2 text-[14px]">
<div v-if="hasOutputs" class="flex flex-row gap-2 text-[14px]">
<p class="mt-0 text-base-foreground">
<i18n-t keypath="linearMode.welcome.getStarted" tag="span">
<template #runButton>
@@ -38,18 +41,10 @@ const appModeStore = useAppModeStore()
</p>
</div>
<div v-else class="flex flex-row gap-2">
<Button
variant="textonly"
size="lg"
@click="appModeStore.setMode('graph')"
>
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.welcome.backToWorkflow') }}
</Button>
<Button
variant="primary"
size="lg"
@click="appModeStore.setMode('builder:select')"
>
<Button variant="primary" size="lg" @click="setMode('builder:select')">
<i class="icon-[lucide--hammer]" />
{{ t('linearMode.welcome.buildApp') }}
<div

View File

@@ -12,6 +12,7 @@ import {
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import type {
@@ -275,7 +276,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
orientation="horizontal"
selection-behavior="replace"
by="id"
class="min-w-0"
class="min-w-0 px-4 pb-4"
@update:model-value="onSelectionChange"
>
<ListboxContent as-child>
@@ -287,23 +288,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
>
<div class="flex items-center gap-0.5 mx-auto w-fit">
<div v-if="queueCount > 0" class="shrink-0 flex items-center gap-0.5">
<div
class="shrink-0 p-1 border-2 border-transparent relative"
data-testid="linear-job"
>
<div
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
>
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
</div>
<div
v-if="queueCount > 1"
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
v-text="queueCount"
/>
</div>
<OutputHistoryActiveQueueItem :queue-count="queueCount" />
<div
v-if="hasActiveContent || visibleHistory.length > 0"
class="border-l border-border-default h-12 shrink-0 mx-4"

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
const { queueCount } = defineProps<{
queueCount: number
}>()
const { t } = useI18n()
const commandStore = useCommandStore()
function clearQueue(close: () => void) {
void commandStore.execute('Comfy.ClearPendingTasks')
close()
}
</script>
<template>
<div
class="shrink-0 p-1 border-2 border-transparent relative"
data-testid="linear-job"
>
<Popover side="top" :show-arrow="false" @focus-outside.prevent>
<template #button>
<Button
v-tooltip.top="t('linearMode.queue.clickToClear')"
:aria-label="t('linearMode.queue.clickToClear')"
variant="textonly"
size="unset"
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
>
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<Button
:disabled="queueCount === 0"
variant="textonly"
class="text-destructive-background px-4 text-sm"
@click="clearQueue(close)"
>
<i class="icon-[lucide--list-x]" />
{{ t('linearMode.queue.clear') }}
</Button>
</template>
</Popover>
<div
v-if="queueCount > 1"
aria-hidden="true"
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
v-text="queueCount"
/>
</div>
</template>

View File

@@ -14,11 +14,9 @@ const { apiTarget } = vi.hoisted(() => ({
apiTarget: new EventTarget()
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
get isAppMode() {
return isAppModeRef.value
}
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
isAppMode: isAppModeRef
})
}))

View File

@@ -5,12 +5,12 @@ import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeO
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
export const useLinearOutputStore = defineStore('linearOutput', () => {
const appModeStore = useAppModeStore()
const { isAppMode } = useAppMode()
const executionStore = useExecutionStore()
const jobPreviewStore = useJobPreviewStore()
@@ -220,7 +220,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
watch(
() => executionStore.activeJobId,
(jobId, oldJobId) => {
if (!appModeStore.isAppMode) return
if (!isAppMode.value) return
if (oldJobId && oldJobId !== jobId) {
onJobComplete(oldJobId)
}
@@ -233,7 +233,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
watch(
() => jobPreviewStore.previewsByPromptId,
(previews) => {
if (!appModeStore.isAppMode) return
if (!isAppMode.value) return
const jobId = executionStore.activeJobId
if (!jobId) return
const url = previews[jobId]
@@ -243,7 +243,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
)
watch(
() => appModeStore.isAppMode,
isAppMode,
(active, wasActive) => {
if (active) {
api.addEventListener('executed', handleExecuted)

View File

@@ -50,6 +50,10 @@
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<AppOutput
v-if="lgraphNode?.constructor?.nodeData?.output_node"
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="flex flex-col justify-center items-center relative"
@@ -284,6 +288,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import { LayoutSource } from '@/renderer/core/layout/types'
import AppOutput from '@/renderer/extensions/linearMode/AppOutput.vue'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'

View File

@@ -28,7 +28,6 @@
<div
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
:data-widget-name="widget.name"
>
<!-- Widget Input Slot Dot -->
<div
@@ -54,21 +53,24 @@
/>
</div>
<!-- Widget Component -->
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'text-node-stroke-error font-bold'
)
"
@update:model-value="widget.updateHandler"
/>
<AppInput :id="widget.id" :name="widget.name">
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'text-node-stroke-error font-bold'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
@@ -84,11 +86,14 @@ import type {
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useSettingStore } from '@/platform/settings/settingStore'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
// Import widget components directly
@@ -98,10 +103,7 @@ import {
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
@@ -134,6 +136,8 @@ function handleBringToFront() {
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -159,9 +163,11 @@ const widgetValueStore = useWidgetValueStore()
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
@@ -191,7 +197,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const { slotMetadata } = widget
// Get metadata from store (registered during BaseWidget.setNodeId)
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const bareWidgetId = widget.nodeId ?? nodeId
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, widget.name)
: undefined
@@ -236,15 +242,27 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined ? String(widget.nodeId) : undefined
)
}
result.push({
advanced: widget.options?.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError:
nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
) ?? false,
hidden: widget.options?.hidden ?? false,
id: widget.nodeId ?? nodeId,
name: widget.name,
type: widget.type,
vueComponent,

View File

@@ -1,66 +1,67 @@
import { defineStore } from 'pinia'
import { whenever } from '@vueuse/core'
import { reactive, readonly, computed, ref, watch } from 'vue'
import { reactive, computed, watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { t } from '@/i18n'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode } = useAppMode()
const selectedInputs = reactive<[NodeId, string][]>([])
const selectedOutputs = reactive<NodeId[]>([])
const mode = ref<AppMode>('graph')
const builderSaving = ref(false)
const hasOutputs = computed(() => !!selectedOutputs.length)
const enableAppBuilder = ref(true)
const isBuilderMode = computed(
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
)
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || mode.value === 'builder:select'
)
const isBuilderSaving = computed(
() => builderSaving.value && isBuilderMode.value
)
function loadSelections(data: Partial<LinearData> | undefined) {
selectedInputs.splice(0, selectedInputs.length, ...(data?.inputs ?? []))
selectedOutputs.splice(0, selectedOutputs.length, ...(data?.outputs ?? []))
}
function resetSelectedToWorkflow() {
const { activeWorkflow } = workflowStore
if (!activeWorkflow) return
const { activeState } = activeWorkflow.changeTracker
selectedInputs.splice(
0,
selectedInputs.length,
...(activeState.extra?.linearData?.inputs ?? [])
)
selectedOutputs.splice(
0,
selectedOutputs.length,
...(activeState.extra?.linearData?.outputs ?? [])
)
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
}
function saveSelectedToWorkflow() {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs]
function flushSelections() {
const workflow = workflowStore.activeWorkflow
if (workflow) {
workflow.dirtyLinearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs]
}
}
}
whenever(() => workflowStore.activeWorkflow, resetSelectedToWorkflow, {
immediate: true
})
watch(
() => workflowStore.activeWorkflow,
(newWorkflow, oldWorkflow) => {
// Persist in-progress builder selections to the outgoing workflow
if (oldWorkflow && isBuilderMode.value) {
oldWorkflow.dirtyLinearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs]
}
}
// Load from incoming workflow: dirty state first, then persisted
if (newWorkflow) {
loadSelections(
newWorkflow.dirtyLinearData ??
newWorkflow.changeTracker?.activeState?.extra?.linearData
)
} else {
loadSelections(undefined)
}
},
{ immediate: true }
)
watch(
() => mode.value === 'builder:select',
@@ -76,30 +77,18 @@ export const useAppModeStore = defineStore('appMode', () => {
)
return
const workflow = workflowStore.activeWorkflow
if (workflow) workflow.dirtyLinearData = null
resetSelectedToWorkflow()
mode.value = 'graph'
setMode('graph')
}
return {
mode: readonly(mode),
enableAppBuilder: readonly(enableAppBuilder),
exitBuilder,
isBuilderMode,
isAppMode,
isGraphMode,
isBuilderSaving,
hasOutputs,
flushSelections,
resetSelectedToWorkflow,
saveSelectedToWorkflow,
selectedInputs,
selectedOutputs,
setBuilderSaving: (newBuilderSaving: boolean) => {
if (!isBuilderMode.value) return
builderSaving.value = newBuilderSaving
},
setMode: (newMode: AppMode) => {
if (newMode === mode.value) return
mode.value = newMode
}
selectedOutputs
}
})

View File

@@ -15,14 +15,6 @@ import type {
*/
type WidgetKey = `${NodeId}:${string}`
/**
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
* e.g., "graph1:subgraph2:42" → "42"
*/
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
}
export interface WidgetState<
TValue = unknown,
TType extends string = string,

View File

@@ -226,7 +226,10 @@ export function createMockChangeTracker(
undoQueue: [],
redoQueue: [],
changeCount: 0,
checkState: vi.fn(),
reset: vi.fn(),
restore: vi.fn(),
store: vi.fn(),
...overrides
}
return partial as Partial<ChangeTracker> as ChangeTracker

View File

@@ -13,7 +13,7 @@
<GraphCanvas @ready="onGraphReady" />
</div>
<LinearView v-if="linearMode" />
<BuilderToolbar v-if="appModeStore.isBuilderMode" />
<BuilderToolbar v-if="isBuilderMode" />
</div>
<GlobalToast />
@@ -70,7 +70,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { setupAutoQueueHandler } from '@/services/autoQueueService'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -88,12 +88,10 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { electronAPI } from '@/utils/envUtil'
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
import { useBuilderSave } from '@/components/builder/useBuilderSave'
import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
setupAutoQueueHandler()
useBuilderSave()
useProgressFavicon()
useBrowserTabTitle()
@@ -106,7 +104,7 @@ const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const appModeStore = useAppModeStore()
const { isBuilderMode } = useAppMode()
const { linearMode } = storeToRefs(useCanvasStore())
const telemetry = useTelemetry()

View File

@@ -2,9 +2,11 @@
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
@@ -12,17 +14,27 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAppMode } from '@/composables/useAppMode'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const { isBuilderMode, isArrangeMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
@@ -30,13 +42,34 @@ const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
// Builder panel is always on the opposite side of the sidebar.
// In arrange mode we render 3 panels to match the overlay structure,
// so the same stateKey percentage maps to the same pixel width.
const showLeftBuilder = computed(
() => !sidebarOnLeft.value && isArrangeMode.value
)
const showRightBuilder = computed(
() => sidebarOnLeft.value && isArrangeMode.value
)
const hasLeftPanel = computed(
() => (sidebarOnLeft.value && activeTab.value) || !sidebarOnLeft.value
() =>
isArrangeMode.value ||
(sidebarOnLeft.value && activeTab.value) ||
(!sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value)
)
const hasRightPanel = computed(
() => sidebarOnLeft.value || (!sidebarOnLeft.value && activeTab.value)
() =>
isArrangeMode.value ||
(sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value) ||
(!sidebarOnLeft.value && activeTab.value)
)
function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
if (isBuilder) return BUILDER_MIN_SIZE
if (isHidden) return undefined
return SIDEBAR_MIN_SIZE
}
const bottomLeftRef = useTemplateRef('bottomLeftRef')
const bottomRightRef = useTemplateRef('bottomRightRef')
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
@@ -44,7 +77,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
<template>
<div class="absolute w-full h-full">
<div
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full"
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
>
<div class="flex h-full items-center">
<WorkflowTabs />
@@ -74,63 +107,96 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</div>
<Splitter
v-else
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-1' } }"
:key="isArrangeMode ? 'arrange' : 'normal'"
class="h-[calc(100%-var(--workflow-tabs-height))] w-full border-none bg-comfy-menu-secondary-bg"
:state-key="isArrangeMode ? 'builder-splitter' : undefined"
:state-storage="isArrangeMode ? 'local' : undefined"
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
>
<SplitterPanel
v-if="hasLeftPanel"
id="linearLeftPanel"
:size="1"
class="min-w-min outline-none"
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
:min-size="
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
"
:style="
showRightBuilder && !activeTab ? { display: 'none' } : undefined
"
:class="
cn(
'outline-none arrange-panel',
showLeftBuilder ? 'bg-comfy-menu-bg min-w-78' : 'min-w-min'
)
"
>
<div v-if="showLeftBuilder" class="h-full overflow-y-auto">
<AppBuilder />
</div>
<div
v-if="sidebarOnLeft && activeTab"
v-else-if="sidebarOnLeft && activeTab"
class="flex h-full border-border-subtle border-r"
>
<ExtensionSlot :extension="activeTab" />
</div>
<LinearControls
v-else
v-else-if="!isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
/>
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
:size="98"
:size="isArrangeMode ? CENTER_PANEL_SIZE : 98"
class="flex flex-col min-w-0 gap-4 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
>
<LinearProgressBar
class="absolute top-0 left-0 w-[calc(100%+16px)] z-21"
/>
<LinearPreview :run-button-click="linearWorkflowRef?.runButtonClick" />
<div class="absolute z-21 top-1 left-1">
<AppModeToolbar v-if="!appModeStore.isBuilderMode" />
<div class="absolute z-21 top-4 left-4">
<AppModeToolbar v-if="!isBuilderMode" />
</div>
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />
<div ref="bottomRightRef" class="absolute z-20 bottom-24 right-4" />
<div ref="bottomLeftRef" class="absolute z-20 bottom-7 left-4" />
<div ref="bottomRightRef" class="absolute z-20 bottom-7 right-4" />
<div
class="absolute z-20 bottom-4 right-4 text-base-foreground flex items-center gap-4"
:class="
cn(
'absolute z-20 bottom-4 text-base-foreground flex items-center gap-2',
sidebarOnLeft ? 'left-4' : 'right-4'
)
"
>
<TypeformPopoverButton
data-tf-widget="gmVqFi8l"
:align="
settingStore.get('Comfy.Sidebar.Location') === 'left'
? 'end'
: 'start'
"
:align="sidebarOnLeft ? 'start' : 'end'"
/>
<div class="flex flex-col text-sm text-muted-foreground">
<span>{{ t('linearMode.beta') }}</span>
<span>{{ t('linearMode.giveFeedback') }}</span>
</div>
</div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"
id="linearRightPanel"
:size="1"
class="min-w-min outline-none"
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
:min-size="
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
"
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
:class="
cn(
'outline-none arrange-panel',
showRightBuilder ? 'bg-comfy-menu-bg min-w-78' : 'min-w-min'
)
"
>
<div v-if="showRightBuilder" class="h-full overflow-y-auto">
<AppBuilder />
</div>
<LinearControls
v-if="sidebarOnLeft"
v-else-if="sidebarOnLeft && !isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomRightRef) ?? undefined"
/>
@@ -144,3 +210,21 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</Splitter>
</div>
</template>
<style scoped>
:deep(.p-splitter-gutter) {
pointer-events: auto;
}
:deep(.p-splitter-gutter:hover),
:deep(.p-splitter-gutter[data-p-gutter-resizing='true']) {
transition: background-color 0.2s ease 300ms;
background-color: var(--p-primary-color);
}
/* Hide gutter next to hidden arrange panels */
:deep(.arrange-panel[style*='display: none'] + .p-splitter-gutter),
:deep(.p-splitter-gutter + .arrange-panel[style*='display: none']) {
display: none;
}
</style>