diff --git a/src/composables/bottomPanelTabs/useLogsTerminal.ts b/src/composables/bottomPanelTabs/useLogsTerminal.ts
new file mode 100644
index 0000000000..25f413b25f
--- /dev/null
+++ b/src/composables/bottomPanelTabs/useLogsTerminal.ts
@@ -0,0 +1,123 @@
+import { until, useEventListener } from '@vueuse/core'
+import { storeToRefs } from 'pinia'
+import type { Ref } from 'vue'
+import { onMounted, onScopeDispose, ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+
+import type { LogEntry, LogsWsMessage } from '@/schemas/apiSchema'
+import { api } from '@/scripts/api'
+import { useExecutionStore } from '@/stores/executionStore'
+
+type TerminalLike = {
+ write: (data: string) => void
+ reset: () => void
+ scrollToBottom: () => void
+}
+
+/**
+ * Drives the built-in logs terminal: initial load, live `logs` stream, and
+ * full resync when the backend WebSocket reconnects (e.g., after a reboot).
+ *
+ * Listeners are registered synchronously so we cannot miss a `reconnected`
+ * event during the mount-time fetch/subscribe awaits. In-flight fetches are
+ * tied to AbortControllers so that:
+ * - rapid double-reconnects don't interleave writes / double-subscribe
+ * - unmount mid-fetch never writes to a disposed terminal
+ */
+export function useLogsTerminal(
+ terminal: Readonly
[>
+) {
+ const { t } = useI18n()
+ const errorMessage = ref('')
+ const loading = ref(true)
+
+ let mountController: AbortController | undefined
+ let resyncController: AbortController | undefined
+
+ const writeEntries = (entries: LogEntry[]) => {
+ terminal.value?.write(entries.map((e) => e.m).join(''))
+ }
+
+ const resyncLogs = async () => {
+ // Cancel both the in-flight mount fetch and any prior resync so a late
+ // mount response can't write a stale snapshot on top of a freshly-reset
+ // terminal after we've already written the post-reconnect view.
+ mountController?.abort()
+ resyncController?.abort()
+ const controller = new AbortController()
+ resyncController = controller
+ const { signal } = controller
+
+ try {
+ const logs = await api.getRawLogs()
+ if (signal.aborted || !terminal.value) return
+ terminal.value.reset()
+ writeEntries(logs.entries)
+ terminal.value.scrollToBottom()
+ // Backend lost the per-client log subscription across the restart;
+ // re-subscribe so new runtime logs stream over the fresh WebSocket.
+ await api.subscribeLogs(true)
+ if (signal.aborted) return
+ errorMessage.value = ''
+ loading.value = false
+ } catch (err) {
+ if (signal.aborted) return
+ console.error('Error resyncing logs after reconnect', err)
+ errorMessage.value = t('logsTerminal.resyncError')
+ }
+ }
+
+ // Register listeners synchronously, before any awaits, so a reconnect
+ // fired during mount cannot be missed. useEventListener handles cleanup
+ // on scope dispose.
+ useEventListener(api, 'logs', (e: CustomEvent) => {
+ writeEntries(e.detail.entries)
+ })
+ useEventListener(api, 'reconnected', () => {
+ void resyncLogs()
+ })
+
+ onMounted(async () => {
+ if (!terminal.value) await until(terminal).toBeTruthy()
+
+ const controller = new AbortController()
+ mountController = controller
+ const { signal } = controller
+
+ try {
+ const logs = await api.getRawLogs()
+ if (signal.aborted || !terminal.value) return
+ writeEntries(logs.entries)
+ } catch (err) {
+ if (signal.aborted) return
+ console.error('Error loading logs', err)
+ errorMessage.value = t('logsTerminal.loadError')
+ loading.value = false
+ return
+ }
+
+ const { clientId } = storeToRefs(useExecutionStore())
+ if (!clientId.value) await until(clientId).not.toBeNull()
+ if (signal.aborted) return
+
+ try {
+ await api.subscribeLogs(true)
+ } catch (err) {
+ if (signal.aborted) return
+ console.error('Error subscribing to logs', err)
+ }
+
+ if (!signal.aborted) loading.value = false
+ })
+
+ onScopeDispose(() => {
+ mountController?.abort()
+ resyncController?.abort()
+ if (!api.clientId) return
+ api.subscribeLogs(false).catch((err) => {
+ console.error('Error unsubscribing from logs', err)
+ })
+ })
+
+ return { errorMessage, loading }
+}
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 6450f09142..3d4cc8dd96 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -1160,6 +1160,10 @@
"saveAsTemplate": "Save as template",
"enterName": "Enter name"
},
+ "logsTerminal": {
+ "loadError": "Unable to load logs, please ensure you have updated your ComfyUI backend.",
+ "resyncError": "Unable to resync logs after the backend reconnected. Reopen the console to retry."
+ },
"workflowService": {
"exportWorkflow": "Export Workflow",
"enterFilename": "Enter the filename",
diff --git a/src/platform/assets/composables/media/useInternalFilesApi.ts b/src/platform/assets/composables/media/useInternalFilesApi.ts
deleted file mode 100644
index acba1ee8de..0000000000
--- a/src/platform/assets/composables/media/useInternalFilesApi.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { computed } from 'vue'
-
-import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
-import { useAssetsStore } from '@/stores/assetsStore'
-
-/**
- * Composable for fetching media assets from local environment
- * Uses AssetsStore for centralized state management
- */
-export function useInternalFilesApi(directory: 'input' | 'output') {
- const assetsStore = useAssetsStore()
-
- const media = computed(() =>
- directory === 'input' ? assetsStore.inputAssets : assetsStore.historyAssets
- )
-
- const loading = computed(() =>
- directory === 'input'
- ? assetsStore.inputLoading
- : assetsStore.historyLoading
- )
-
- const error = computed(() =>
- directory === 'input' ? assetsStore.inputError : assetsStore.historyError
- )
-
- const fetchMediaList = async (): Promise => {
- if (directory === 'input') {
- await assetsStore.updateInputs()
- return assetsStore.inputAssets
- } else {
- await assetsStore.updateHistory()
- return assetsStore.historyAssets
- }
- }
-
- const refresh = () => fetchMediaList()
-
- const loadMore = async (): Promise => {
- if (directory === 'output') {
- await assetsStore.loadMoreHistory()
- }
- }
-
- const hasMore = computed(() => {
- return directory === 'output' ? assetsStore.hasMoreHistory : false
- })
-
- const isLoadingMore = computed(() => {
- return directory === 'output' ? assetsStore.isLoadingMore : false
- })
-
- return {
- media,
- loading,
- error,
- fetchMediaList,
- refresh,
- loadMore,
- hasMore,
- isLoadingMore
- }
-}
diff --git a/src/platform/assets/composables/media/useMediaAssets.ts b/src/platform/assets/composables/media/useMediaAssets.ts
deleted file mode 100644
index 46bb6111be..0000000000
--- a/src/platform/assets/composables/media/useMediaAssets.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { isCloud } from '@/platform/distribution/types'
-
-import type { IAssetsProvider } from './IAssetsProvider'
-import { useAssetsApi } from './useAssetsApi'
-import { useInternalFilesApi } from './useInternalFilesApi'
-
-/**
- * Factory function that returns the appropriate media assets implementation
- * based on the current distribution (cloud vs internal)
- * @param directory - The directory to fetch assets from
- * @returns IAssetsProvider implementation
- */
-export function useMediaAssets(directory: 'input' | 'output'): IAssetsProvider {
- return isCloud ? useAssetsApi(directory) : useInternalFilesApi(directory)
-}
diff --git a/src/renderer/extensions/linearMode/useOutputHistory.test.ts b/src/renderer/extensions/linearMode/useOutputHistory.test.ts
index 15826965f0..85371b156a 100644
--- a/src/renderer/extensions/linearMode/useOutputHistory.test.ts
+++ b/src/renderer/extensions/linearMode/useOutputHistory.test.ts
@@ -23,8 +23,8 @@ const selectAsLatestFn = vi.fn()
const resolveIfReadyFn = vi.fn()
const resolvedOutputsCacheRef = new Map()
-vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
- useMediaAssets: () => ({
+vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
+ useAssetsApi: () => ({
media: mediaRef,
loading: ref(false),
error: ref(null),
diff --git a/src/renderer/extensions/linearMode/useOutputHistory.ts b/src/renderer/extensions/linearMode/useOutputHistory.ts
index c723c977f7..317c99de5e 100644
--- a/src/renderer/extensions/linearMode/useOutputHistory.ts
+++ b/src/renderer/extensions/linearMode/useOutputHistory.ts
@@ -3,7 +3,7 @@ import type { ComputedRef } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
-import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
+import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -25,7 +25,7 @@ export function useOutputHistory(): {
isWorkflowActive: ComputedRef
cancelActiveWorkflowJobs: () => Promise
} {
- const backingOutputs = useMediaAssets('output')
+ const backingOutputs = useAssetsApi('output')
void backingOutputs.fetchMediaList()
const linearStore = useLinearOutputStore()
const workflowStore = useWorkflowStore()
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts
index deab218513..fc485a4091 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.test.ts
@@ -69,8 +69,8 @@ const { mockMediaAssets } = vi.hoisted(() => {
}
})
-vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
- useMediaAssets: () => mockMediaAssets
+vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
+ useAssetsApi: () => mockMediaAssets
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
index 970124615e..541ac04a3b 100644
--- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
-import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
+import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
@@ -47,7 +47,7 @@ const modelValue = defineModel({
const { t } = useI18n()
-const outputMediaAssets = useMediaAssets('output')
+const outputMediaAssets = useAssetsApi('output')
const transformCompatProps = useTransformCompatOverlayProps()
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
index 5b6bff0145..4c688642dd 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.test.ts
@@ -37,8 +37,8 @@ function createMockMediaAssets() {
let mockMediaAssets = createMockMediaAssets()
-vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
- useMediaAssets: () => mockMediaAssets
+vi.mock('@/platform/assets/composables/media/useAssetsApi', () => ({
+ useAssetsApi: () => mockMediaAssets
}))
vi.mock('@/platform/assets/composables/useAssetFilterOptions', () => ({
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
index 8450bb0f46..12c1584d79 100644
--- a/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectItems.ts
@@ -25,7 +25,7 @@ import type { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
-import type { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
+import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
import type { AssetKind } from '@/types/widgetTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -65,7 +65,7 @@ interface UseWidgetSelectItemsOptions {
>
modelValue: Ref
assetKind: MaybeRefOrGetter
- outputMediaAssets: ReturnType
+ outputMediaAssets: IAssetsProvider
assetData: ReturnType | null
isAssetMode: MaybeRefOrGetter
}
]