From 6d5cd5265d974fcf6c1fdf73ca6639e419a00651 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 01:40:36 +0100 Subject: [PATCH 1/6] feat(settings): Persist file manager hidden files default The file manager's Hidden toggle now starts from a setting persisted on the agent instead of always starting off. Hidden files are shown by default and the preference can be changed from the Settings page. --- src/components/FileBrowser.vue | 19 +++++++++++-- src/views/SettingsView.vue | 52 +++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/components/FileBrowser.vue b/src/components/FileBrowser.vue index ff99a2d..73cd5b6 100644 --- a/src/components/FileBrowser.vue +++ b/src/components/FileBrowser.vue @@ -475,7 +475,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from "vue" import { Codemirror } from "vue-codemirror"; import { yaml } from "@codemirror/lang-yaml"; import { oneDark } from "@codemirror/theme-one-dark"; -import { createDeploymentFileApi, type FileBrowserApi, type FileInfo } from "@/services/api"; +import { configApi, createDeploymentFileApi, type FileBrowserApi, type FileInfo } from "@/services/api"; import { useNotificationsStore } from "@/stores/notifications"; import { toComposeRelativePath, type ComposeMount } from "@/utils/compose"; @@ -576,9 +576,21 @@ const permissionsModeOctal = computed(() => { return "0" + ((n >> 6) & 7) + ((n >> 3) & 7) + (n & 7); }); -const showHiddenFiles = ref(false); +const showHiddenFiles = ref(true); const hideSystemFolders = ref(true); +const loadShowHiddenSetting = async () => { + try { + const response = await configApi.get("files.show_hidden"); + const value = response.data.entry?.value; + if (typeof value === "boolean") { + showHiddenFiles.value = value; + } + } catch { + // Keep the default when the setting cannot be loaded + } +}; + const SYSTEM_FOLDER_NAMES = new Set(["proc", "sys", "dev", "boot", "run", "lost+found", "var", "tmp", "snap"]); const viewMode = ref<"list" | "grid">("list"); @@ -1078,7 +1090,8 @@ watch(showHiddenFiles, () => { fetchFiles(); }); -onMounted(() => { +onMounted(async () => { + await loadShowHiddenSetting(); refreshFiles(); document.addEventListener("click", onDocumentClick); }); diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index b7c12c2..32a48fa 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -79,6 +79,27 @@ +
+
+ +

File Manager

+
+
+
+ + Whether dotfiles are visible when the file manager opens. +
+
+
+
@@ -972,7 +993,7 @@ From 541cac0fdc813e4379a3972b1c92ef1fb4357faf Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 01:40:36 +0100 Subject: [PATCH 2/6] feat(deployments): Optional app template for image and compose deploys Image and compose deployments can now select an app template. For image deploys the selection prefills the container port and bind mounts while the generated compose keeps the user's image. For both modes the selection is sent with the deployment so the agent prepares directories, environment files and ownership. Compose content written by the user stays untouched. Required mounts are preselected but stay editable in every mode, and the full selection is sent so deselecting a required mount sticks. --- src/components/NewDeploymentModal.vue | 110 +++++++++++++++++++++++--- src/services/api.ts | 2 + 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/src/components/NewDeploymentModal.vue b/src/components/NewDeploymentModal.vue index aefed94..3ddac92 100644 --- a/src/components/NewDeploymentModal.vue +++ b/src/components/NewDeploymentModal.vue @@ -277,6 +277,25 @@
+
+ + + + Your compose file stays as-is; the app's directories, default environment files and permissions + are prepared on deploy + +
+
@@ -343,6 +362,25 @@ Docker Hub, GHCR, or any registry
+
+ + + + Prepares the app's directories and environment file; bind mounts can be enabled in the next + step + +
+
+
+ App Template + {{ templatePresetApp.name }} +
Domain @@ -1448,7 +1489,33 @@ const existingDeployments = ref([]); const extensions = shallowRef([yaml(), oneDark]); const deploymentMode = ref<"" | "easy" | "compose" | "image">(""); +const templatePreset = ref(""); const showRegistryPassword = ref(false); + +const templatePresetApp = computed(() => quickApps.value.find((a) => a.id === templatePreset.value) || null); + +const effectiveTemplateId = computed(() => { + if (deploymentMode.value === "easy") { + return selectedQuickApp.value !== "custom" ? selectedQuickApp.value : ""; + } + return templatePreset.value; +}); + +const onTemplatePresetChange = () => { + const app = templatePresetApp.value; + if (!app) { + form.mounts = []; + return; + } + if (deploymentMode.value === "image" && app.container_port) { + form.networking.ports = [{ containerPort: app.container_port, hostPort: "", expose: true }]; + } + form.mounts = (app.mounts || []).map((m) => ({ + id: m.id, + enabled: m.required, + type: m.type, + })); +}; const existingCredentials = ref([]); const loadingCredentials = ref(false); @@ -1674,8 +1741,10 @@ const selectedQuickAppName = computed(() => { }); const selectedTemplateMounts = computed(() => { - if (selectedQuickApp.value === "custom" || !selectedQuickApp.value) return []; - const app = quickApps.value.find((a) => a.id === selectedQuickApp.value); + // Compose mode keeps the user's compose untouched, so mount toggles would have no effect. + if (deploymentMode.value === "compose") return []; + if (!effectiveTemplateId.value) return []; + const app = quickApps.value.find((a) => a.id === effectiveTemplateId.value); return app?.mounts || []; }); @@ -1861,8 +1930,6 @@ const selectQuickApp = async (app: QuickApp) => { const toggleMount = (mountId: string) => { const mount = form.mounts.find((m) => m.id === mountId); if (mount) { - const templateMount = selectedTemplateMounts.value.find((m) => m.id === mountId); - if (templateMount?.required) return; mount.enabled = !mount.enabled; } }; @@ -2210,14 +2277,13 @@ const nextStep = async () => { ) { generatingCompose.value = true; try { - const enabledMounts = form.mounts.filter((m) => m.enabled); const firstPort = form.networking.ports[0] || { containerPort: 80, hostPort: "" }; const response = await templatesApi.generateCompose(selectedQuickApp.value, { name: form.name, container_port: firstPort.containerPort, map_ports: !!firstPort.hostPort, host_port: firstPort.hostPort || undefined, - mounts: enabledMounts.length > 0 ? enabledMounts : undefined, + mounts: form.mounts.length > 0 ? form.mounts : undefined, }); form.composeContent = response.data.content; } catch (error: any) { @@ -2230,7 +2296,31 @@ const nextStep = async () => { } if (currentStep.value === 2 && deploymentMode.value === "image") { - form.composeContent = buildComposeFromImage(); + if (templatePreset.value) { + generatingCompose.value = true; + try { + const firstPort = form.networking.ports[0] || { containerPort: 80, hostPort: "" }; + const response = await templatesApi.generateCompose(templatePreset.value, { + name: form.name, + image: form.image, + container_port: firstPort.containerPort, + map_ports: !!firstPort.hostPort, + host_port: firstPort.hostPort || undefined, + // The full selection list, so deselected required mounts are not + // re-applied as defaults by the agent. + mounts: form.mounts.length > 0 ? form.mounts : undefined, + }); + form.composeContent = response.data.content; + } catch (error: any) { + const msg = error.response?.data?.error || error.message; + notifications.error("Failed to generate compose", msg); + generatingCompose.value = false; + return; + } + generatingCompose.value = false; + } else { + form.composeContent = buildComposeFromImage(); + } } if (currentStep.value < steps.value.length) { @@ -2292,6 +2382,7 @@ watch( errors.composeContent = ""; selectedQuickApp.value = ""; selectedTemplateContent.value = ""; + templatePreset.value = ""; deploymentMode.value = ""; currentStep.value = 0; generatedSubdomain.value = ""; @@ -2310,6 +2401,7 @@ watch(deploymentMode, (newMode, oldMode) => { if (oldMode && newMode !== oldMode) { selectedQuickApp.value = ""; selectedTemplateContent.value = ""; + templatePreset.value = ""; form.name = ""; form.image = ""; form.composeContent = ""; @@ -2403,7 +2495,7 @@ const handleCreate = async () => { const payload: Record = { name: form.name, compose_content: form.composeContent, - template_id: selectedQuickApp.value !== "custom" ? selectedQuickApp.value : undefined, + template_id: effectiveTemplateId.value || undefined, env_vars: form.envVars.filter((e) => e.key), auto_start: form.autoStart, use_shared_database: form.database.useSharedDatabase && form.database.mode === "shared", diff --git a/src/services/api.ts b/src/services/api.ts index 3b5eeaf..5a16923 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -369,6 +369,7 @@ export const aiApi = { auto_run: boolean; message: string; context?: string; + seed?: boolean; }) => apiClient.post("/ai/sessions", body), sessionMessage: (id: string, message: string, context?: string) => apiClient.post(`/ai/sessions/${id}/messages`, { message, context }), @@ -408,6 +409,7 @@ export interface MountSelection { export interface ComposeGenerateOptions { name: string; + image?: string; container_port?: number; map_ports?: boolean; host_port?: string; From 963077e27f8a65231455b126fb06f9cfd54f6b6c Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 01:40:36 +0100 Subject: [PATCH 3/6] feat(ai): Keep seeded prompts out of the assistant transcript Prompts composed by the product when opening the assistant are flagged so the agent excludes them from the visible conversation. Only messages the user types appear in the chat. --- src/stores/assist.test.ts | 3 +++ src/stores/assist.ts | 9 +++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/stores/assist.test.ts b/src/stores/assist.test.ts index 030c742..d580978 100644 --- a/src/stores/assist.test.ts +++ b/src/stores/assist.test.ts @@ -63,12 +63,15 @@ describe("assist store", () => { const store = useAssistStore(); await store.open({ scope: "deployment", deployment: "myapp", subject: "myapp", seedMessage: "diagnose" }); + // Seeded prompts are flagged so the agent keeps them out of the + // visible transcript; the model still receives them. expect(aiApi.createSession).toHaveBeenCalledWith({ scope: "deployment", deployment: "myapp", auto_run: true, message: "diagnose", context: undefined, + seed: true, }); expect(store.session?.id).toBe("ais_1"); }); diff --git a/src/stores/assist.ts b/src/stores/assist.ts index dbd0dd1..3d6b206 100644 --- a/src/stores/assist.ts +++ b/src/stores/assist.ts @@ -16,8 +16,8 @@ export interface AssistContext { scope: "system" | "deployment"; deployment?: string; subject: string; - // seedMessage is the short prompt shown in the transcript; seedContext - // is bulky material (logs, output) sent to the model but not shown. + // seedMessage and seedContext are sent to the model but kept out of + // the visible transcript; only messages the user types are shown. seedMessage?: string; seedContext?: string; autoRun?: boolean; @@ -65,11 +65,11 @@ export const useAssistStore = defineStore("assist", () => { autoRun.value = ctx.autoRun ?? true; if (!(await ensureEnabled())) return; if (ctx.seedMessage) { - await send(ctx.seedMessage, ctx.seedContext); + await send(ctx.seedMessage, ctx.seedContext, true); } } - async function send(message: string, context?: string) { + async function send(message: string, context?: string, seed = false) { if (loading.value) return; error.value = ""; suggestionOutputs.value = {}; @@ -83,6 +83,7 @@ export const useAssistStore = defineStore("assist", () => { auto_run: autoRun.value, message, context, + seed, }); session.value = response.data; } catch (err: any) { From fd5593f09cb8fae446ce920e058aecd1ebec147c Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 8 Jun 2026 01:50:35 +0100 Subject: [PATCH 4/6] feat(terminal): System terminal uses the interactive PTY stream The system terminal now speaks the same raw stream protocol as the container terminal, attached to a real PTY on the host. Interactive and terminal-control programs work, keystrokes reach the running program directly, and resizes propagate. Blocked commands are cancelled with a notice instead of returning a synthetic error. --- src/components/ContainerTerminal.vue | 13 ++- src/views/SystemTerminalView.vue | 169 +++------------------------ 2 files changed, 22 insertions(+), 160 deletions(-) diff --git a/src/components/ContainerTerminal.vue b/src/components/ContainerTerminal.vue index 8c59595..1249f23 100644 --- a/src/components/ContainerTerminal.vue +++ b/src/components/ContainerTerminal.vue @@ -27,7 +27,11 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import "@xterm/xterm/css/xterm.css"; const props = defineProps<{ - containerId: string; + containerId?: string; + // wsPath overrides the container exec endpoint, so any PTY stream + // speaking the same protocol (e.g. the system terminal) can reuse + // this component. + wsPath?: string; }>(); const emit = defineEmits<{ @@ -52,13 +56,14 @@ let explicitCloseMessage = ""; const getWebSocketUrl = () => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const apiUrl = import.meta.env.VITE_API_URL || ""; + const path = props.wsPath || `/api/containers/${props.containerId}/exec`; if (apiUrl.startsWith("http")) { const url = new URL(apiUrl); - return `${protocol}//${url.host}/api/containers/${props.containerId}/exec`; + return `${protocol}//${url.host}${path}`; } - return `${protocol}//${window.location.host}/api/containers/${props.containerId}/exec`; + return `${protocol}//${window.location.host}${path}`; }; let authenticated = false; @@ -166,7 +171,7 @@ const connect = () => { socket.onerror = () => { connectionStatus.value = "error"; - statusMessage.value = "Connection failed. Check if container is running."; + statusMessage.value = props.wsPath ? "Connection failed." : "Connection failed. Check if container is running."; authenticated = false; emit("error", "WebSocket connection failed"); }; diff --git a/src/views/SystemTerminalView.vue b/src/views/SystemTerminalView.vue index 773bcee..45d0f68 100644 --- a/src/views/SystemTerminalView.vue +++ b/src/views/SystemTerminalView.vue @@ -6,11 +6,11 @@

Host shell governed by global terminal protection settings

- - @@ -18,13 +18,12 @@
-
-
-
- -

{{ error || (connecting ? "Connecting..." : "Click Connect to open system terminal") }}

-
-
+
@@ -35,130 +34,11 @@