From ba5a5dcf9ee5ca0dd2d2c94ba468ad84412187fc Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 29 Jan 2026 17:13:19 -0800 Subject: [PATCH 01/34] better connection state tracking in jobcontroller --- pkg/jobcontroller/jobcontroller.go | 179 +++++++++++++++++++++-------- 1 file changed, 131 insertions(+), 48 deletions(-) diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index e9adb0dede..224d715b15 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -66,11 +66,77 @@ func GetJobManagerStatus(ctx context.Context, jobId string) (string, error) { return job.JobManagerStatus, nil } +type connState struct { + actual bool + processed bool + reconciling bool +} + +type connStateManager struct { + sync.Mutex + m map[string]*connState + reconcileCh chan struct{} +} + var ( jobConnStates = make(map[string]string) - jobConnStatesLock sync.Mutex + jobControllerLock sync.Mutex + + connStates = &connStateManager{ + m: make(map[string]*connState), + reconcileCh: make(chan struct{}, 1), + } ) +func connReconcileWorker() { + defer func() { + panichandler.PanicHandler("jobcontroller:connReconcileWorker", recover()) + }() + + for range connStates.reconcileCh { + reconcileAllConns() + } +} + +func reconcileAllConns() { + connStates.Lock() + defer connStates.Unlock() + + for connName, cs := range connStates.m { + if cs.reconciling || cs.actual == cs.processed { + continue + } + + cs.reconciling = true + actual := cs.actual + go reconcileConn(connName, actual) + } +} + +func reconcileConn(connName string, targetState bool) { + defer func() { + panichandler.PanicHandler("jobcontroller:reconcileConn", recover()) + }() + + if targetState { + onConnectionUp(connName) + } else { + onConnectionDown(connName) + } + + connStates.Lock() + defer connStates.Unlock() + if cs, exists := connStates.m[connName]; exists { + cs.processed = targetState + cs.reconciling = false + } + + select { + case connStates.reconcileCh <- struct{}{}: + default: + } +} + func getMetaInt64(meta wshrpc.FileMeta, key string) int64 { val, ok := meta[key] if !ok { @@ -86,6 +152,8 @@ func getMetaInt64(meta wshrpc.FileMeta, key string) int64 { } func InitJobController() { + go connReconcileWorker() + rpcClient := wshclient.GetBareRpcClient() rpcClient.EventListener.On(wps.Event_RouteUp, handleRouteUpEvent) rpcClient.EventListener.On(wps.Event_RouteDown, handleRouteDownEvent) @@ -130,29 +198,72 @@ func handleConnChangeEvent(event *wps.WaveEvent) { return } - if !connStatus.Connected { + var connName string + for _, scope := range event.Scopes { + if strings.HasPrefix(scope, "connection:") { + connName = strings.TrimPrefix(scope, "connection:") + break + } + } + if connName == "" { return } - for _, scope := range event.Scopes { - if strings.HasPrefix(scope, "connection:") { - connName := strings.TrimPrefix(scope, "connection:") - log.Printf("[conn:%s] connection became connected, terminating jobs with TerminateOnReconnect", connName) - go func() { - defer func() { - panichandler.PanicHandler("jobcontroller:handleConnChangeEvent", recover()) - }() - ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelFn() - TerminateJobsOnConn(ctx, connName) - }() + connStates.Lock() + cs, exists := connStates.m[connName] + if !exists { + cs = &connState{actual: false, processed: false, reconciling: false} + connStates.m[connName] = cs + } + cs.actual = connStatus.Connected + connStates.Unlock() + + select { + case connStates.reconcileCh <- struct{}{}: + default: + } +} + +func onConnectionUp(connName string) { + log.Printf("[conn:%s] connection became connected, terminating jobs with TerminateOnReconnect", connName) + ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelFn() + + allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) + if err != nil { + log.Printf("[conn:%s] failed to get jobs for termination: %v", connName, err) + return + } + + var jobsToTerminate []*waveobj.Job + for _, job := range allJobs { + if job.Connection == connName && job.TerminateOnReconnect { + jobsToTerminate = append(jobsToTerminate, job) } } + + log.Printf("[conn:%s] found %d jobs to terminate", connName, len(jobsToTerminate)) + + successCount := 0 + for _, job := range jobsToTerminate { + err = remoteTerminateJobManager(ctx, job) + if err != nil { + log.Printf("[job:%s] error terminating: %v", job.OID, err) + } else { + successCount++ + } + } + + log.Printf("[conn:%s] finished terminating jobs: %d/%d successful", connName, successCount, len(jobsToTerminate)) +} + +func onConnectionDown(connName string) { + log.Printf("[conn:%s] connection became disconnected", connName) } func GetJobConnStatus(jobId string) string { - jobConnStatesLock.Lock() - defer jobConnStatesLock.Unlock() + jobControllerLock.Lock() + defer jobControllerLock.Unlock() status, exists := jobConnStates[jobId] if !exists { return JobConnStatus_Disconnected @@ -161,8 +272,8 @@ func GetJobConnStatus(jobId string) string { } func SetJobConnStatus(jobId string, status string) { - jobConnStatesLock.Lock() - defer jobConnStatesLock.Unlock() + jobControllerLock.Lock() + defer jobControllerLock.Unlock() if status == JobConnStatus_Disconnected { delete(jobConnStates, jobId) } else { @@ -171,8 +282,8 @@ func SetJobConnStatus(jobId string, status string) { } func GetConnectedJobIds() []string { - jobConnStatesLock.Lock() - defer jobConnStatesLock.Unlock() + jobControllerLock.Lock() + defer jobControllerLock.Unlock() var connectedJobIds []string for jobId, status := range jobConnStates { if status == JobConnStatus_Connected { @@ -621,34 +732,6 @@ func ReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts return RestartStreaming(ctx, jobId, true, rtOpts) } -func TerminateJobsOnConn(ctx context.Context, connName string) { - allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) - if err != nil { - log.Printf("[conn:%s] failed to get jobs for termination: %v", connName, err) - return - } - - var jobsToTerminate []*waveobj.Job - for _, job := range allJobs { - if job.Connection == connName && job.TerminateOnReconnect { - jobsToTerminate = append(jobsToTerminate, job) - } - } - - log.Printf("[conn:%s] found %d jobs to terminate", connName, len(jobsToTerminate)) - - successCount := 0 - for _, job := range jobsToTerminate { - err = remoteTerminateJobManager(ctx, job) - if err != nil { - log.Printf("[job:%s] error terminating: %v", job.OID, err) - } else { - successCount++ - } - } - - log.Printf("[conn:%s] finished terminating jobs: %d/%d successful", connName, successCount, len(jobsToTerminate)) -} func ReconnectJobsForConn(ctx context.Context, connName string) error { isConnected, err := conncontroller.IsConnected(connName) From 1159ca3917c5fc5ef66cf8b4fc81f652661165fd Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 30 Jan 2026 11:16:57 -0800 Subject: [PATCH 02/34] remove react.fc typing. move connstatusoverlay to its own file --- docs/src/components/platformcontext.tsx | 12 +- frontend/app/block/blockframe.tsx | 163 +------------------ frontend/app/block/connstatusoverlay.tsx | 169 ++++++++++++++++++++ frontend/app/element/markdown.tsx | 4 +- frontend/app/suggestion/suggestion.tsx | 34 ++-- frontend/app/view/aifilediff/aifilediff.tsx | 4 +- frontend/app/view/launcher/launcher.tsx | 4 +- tsunami/frontend/src/element/modals.tsx | 10 +- 8 files changed, 205 insertions(+), 195 deletions(-) create mode 100644 frontend/app/block/connstatusoverlay.tsx diff --git a/docs/src/components/platformcontext.tsx b/docs/src/components/platformcontext.tsx index 979b00b4c6..a773b5953c 100644 --- a/docs/src/components/platformcontext.tsx +++ b/docs/src/components/platformcontext.tsx @@ -46,13 +46,13 @@ const PlatformProviderInternal = ({ children }: { children: ReactNode }) => { ); }; -export const PlatformProvider: React.FC<{ children: ReactNode }> = ({ children }) => { +export function PlatformProvider({ children }: { children: ReactNode }) { return ( }> {() => {children}} ); -}; +} export const usePlatform = (): PlatformContextProps => { const context = useContext(PlatformContext); @@ -62,7 +62,7 @@ export const usePlatform = (): PlatformContextProps => { return context; }; -const PlatformSelectorButtonInternal: React.FC = () => { +function PlatformSelectorButtonInternal() { const { platform, setPlatform } = usePlatform(); return ( @@ -84,11 +84,11 @@ const PlatformSelectorButtonInternal: React.FC = () => { ); -}; +} -export const PlatformSelectorButton: React.FC = () => { +export function PlatformSelectorButton() { return }>{() => }; -}; +} interface PlatformItemProps { children: ReactNode; diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 8c75b1645a..8663c72fa4 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -3,8 +3,8 @@ import { BlockModel } from "@/app/block/block-model"; import { blockViewToIcon, blockViewToName, ConnectionButton, getBlockHeaderIcon, Input } from "@/app/block/blockutil"; +import { ConnStatusOverlay } from "@/app/block/connstatusoverlay"; import { Button } from "@/app/element/button"; -import { useDimensionsWithCallbackRef } from "@/app/hook/useDimensions"; import { ChangeConnectionBlockModal } from "@/app/modals/conntypeahead"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { @@ -17,8 +17,8 @@ import { useBlockAtom, WOS, } from "@/app/store/global"; -import { useTabModel } from "@/app/store/tab-model"; import { uxCloseBlock } from "@/app/store/keymodel"; +import { useTabModel } from "@/app/store/tab-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; @@ -32,9 +32,7 @@ import { makeIconClass } from "@/util/util"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import clsx from "clsx"; import * as jotai from "jotai"; -import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import * as React from "react"; -import { CopyButton } from "../element/copybutton"; import { BlockFrameProps } from "./blocktypes"; const NumActiveConnColors = 8; @@ -336,161 +334,6 @@ function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): return headerTextElems; } -const ConnStatusOverlay = React.memo( - ({ - nodeModel, - viewModel, - changeConnModalAtom, - }: { - nodeModel: NodeModel; - viewModel: ViewModel; - changeConnModalAtom: jotai.PrimitiveAtom; - }) => { - const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); - const [connModalOpen] = jotai.useAtom(changeConnModalAtom); - const connName = blockData.meta?.connection; - const connStatus = jotai.useAtomValue(getConnStatusAtom(connName)); - const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); - const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); - const width = domRect?.width; - const [showError, setShowError] = React.useState(false); - const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); - const [showWshError, setShowWshError] = React.useState(false); - - React.useEffect(() => { - if (width) { - const hasError = !util.isBlank(connStatus.error); - const showError = hasError && width >= 250 && connStatus.status == "error"; - setShowError(showError); - } - }, [width, connStatus, setShowError]); - - const handleTryReconnect = React.useCallback(() => { - const prtn = RpcApi.ConnConnectCommand( - TabRpcClient, - { host: connName, logblockid: nodeModel.blockId }, - { timeout: 60000 } - ); - prtn.catch((e) => console.log("error reconnecting", connName, e)); - }, [connName]); - - const handleDisableWsh = React.useCallback(async () => { - // using unknown is a hack. we need proper types for the - // connection config on the frontend - const metamaptype: unknown = { - "conn:wshenabled": false, - }; - const data: ConnConfigRequest = { - host: connName, - metamaptype: metamaptype, - }; - try { - await RpcApi.SetConnectionsConfigCommand(TabRpcClient, data); - } catch (e) { - console.log("problem setting connection config: ", e); - } - }, [connName]); - - const handleRemoveWshError = React.useCallback(async () => { - try { - await RpcApi.DismissWshFailCommand(TabRpcClient, connName); - } catch (e) { - console.log("unable to dismiss wsh error: ", e); - } - }, [connName]); - - let statusText = `Disconnected from "${connName}"`; - let showReconnect = true; - if (connStatus.status == "connecting") { - statusText = `Connecting to "${connName}"...`; - showReconnect = false; - } - if (connStatus.status == "connected") { - showReconnect = false; - } - let reconDisplay = null; - let reconClassName = "outlined grey"; - if (width && width < 350) { - reconDisplay = ; - reconClassName = clsx(reconClassName, "text-[12px] py-[5px] px-[6px]"); - } else { - reconDisplay = "Reconnect"; - reconClassName = clsx(reconClassName, "text-[11px] py-[3px] px-[7px]"); - } - const showIcon = connStatus.status != "connecting"; - - const wshConfigEnabled = fullConfig?.connections?.[connName]?.["conn:wshenabled"] ?? true; - React.useEffect(() => { - const showWshErrorTemp = - connStatus.status == "connected" && - connStatus.wsherror && - connStatus.wsherror != "" && - wshConfigEnabled; - - setShowWshError(showWshErrorTemp); - }, [connStatus, wshConfigEnabled]); - - const handleCopy = React.useCallback( - async (e: React.MouseEvent) => { - const errTexts = []; - if (showError) { - errTexts.push(`error: ${connStatus.error}`); - } - if (showWshError) { - errTexts.push(`unable to use wsh: ${connStatus.wsherror}`); - } - const textToCopy = errTexts.join("\n"); - await navigator.clipboard.writeText(textToCopy); - }, - [showError, showWshError, connStatus.error, connStatus.wsherror] - ); - - if (!showWshError && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) { - return null; - } - - return ( -
-
-
- {showIcon && } -
-
{statusText}
- {(showError || showWshError) && ( - - - {showError ?
error: {connStatus.error}
: null} - {showWshError ?
unable to use wsh: {connStatus.wsherror}
: null} -
- )} - {showWshError && ( - - )} -
-
- {showReconnect ? ( -
- -
- ) : null} - {showWshError ? ( -
-
- ) : null} -
-
- ); - } -); - const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { const tabModel = useTabModel(); const isFocused = jotai.useAtomValue(nodeModel.isFocused); @@ -645,7 +488,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { inert={preview ? "1" : undefined} // > - {preview || viewModel == null ? null : ( + {preview || viewModel == null || !manageConnection ? null : ( ; + }) => { + const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); + const [connModalOpen] = jotai.useAtom(changeConnModalAtom); + const connName = blockData.meta?.connection; + const connStatus = jotai.useAtomValue(getConnStatusAtom(connName)); + const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); + const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); + const width = domRect?.width; + const [showError, setShowError] = React.useState(false); + const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); + const [showWshError, setShowWshError] = React.useState(false); + + React.useEffect(() => { + if (width) { + const hasError = !util.isBlank(connStatus.error); + const showError = hasError && width >= 250 && connStatus.status == "error"; + setShowError(showError); + } + }, [width, connStatus, setShowError]); + + const handleTryReconnect = React.useCallback(() => { + const prtn = RpcApi.ConnConnectCommand( + TabRpcClient, + { host: connName, logblockid: nodeModel.blockId }, + { timeout: 60000 } + ); + prtn.catch((e) => console.log("error reconnecting", connName, e)); + }, [connName]); + + const handleDisableWsh = React.useCallback(async () => { + const metamaptype: unknown = { + "conn:wshenabled": false, + }; + const data: ConnConfigRequest = { + host: connName, + metamaptype: metamaptype, + }; + try { + await RpcApi.SetConnectionsConfigCommand(TabRpcClient, data); + } catch (e) { + console.log("problem setting connection config: ", e); + } + }, [connName]); + + const handleRemoveWshError = React.useCallback(async () => { + try { + await RpcApi.DismissWshFailCommand(TabRpcClient, connName); + } catch (e) { + console.log("unable to dismiss wsh error: ", e); + } + }, [connName]); + + let statusText = `Disconnected from "${connName}"`; + let showReconnect = true; + if (connStatus.status == "connecting") { + statusText = `Connecting to "${connName}"...`; + showReconnect = false; + } + if (connStatus.status == "connected") { + showReconnect = false; + } + let reconDisplay = null; + let reconClassName = "outlined grey"; + if (width && width < 350) { + reconDisplay = ; + reconClassName = clsx(reconClassName, "text-[12px] py-[5px] px-[6px]"); + } else { + reconDisplay = "Reconnect"; + reconClassName = clsx(reconClassName, "text-[11px] py-[3px] px-[7px]"); + } + const showIcon = connStatus.status != "connecting"; + + const wshConfigEnabled = fullConfig?.connections?.[connName]?.["conn:wshenabled"] ?? true; + React.useEffect(() => { + const showWshErrorTemp = + connStatus.status == "connected" && + connStatus.wsherror && + connStatus.wsherror != "" && + wshConfigEnabled; + + setShowWshError(showWshErrorTemp); + }, [connStatus, wshConfigEnabled]); + + const handleCopy = React.useCallback( + async (e: React.MouseEvent) => { + const errTexts = []; + if (showError) { + errTexts.push(`error: ${connStatus.error}`); + } + if (showWshError) { + errTexts.push(`unable to use wsh: ${connStatus.wsherror}`); + } + const textToCopy = errTexts.join("\n"); + await navigator.clipboard.writeText(textToCopy); + }, + [showError, showWshError, connStatus.error, connStatus.wsherror] + ); + + if (!showWshError && (isLayoutMode || connStatus.status == "connected" || connModalOpen)) { + return null; + } + + return ( +
+
+
+ {showIcon && } +
+
{statusText}
+ {(showError || showWshError) && ( + + + {showError ?
error: {connStatus.error}
: null} + {showWshError ?
unable to use wsh: {connStatus.wsherror}
: null} +
+ )} + {showWshError && ( + + )} +
+
+ {showReconnect ? ( +
+ +
+ ) : null} + {showWshError ? ( +
+
+ ) : null} +
+
+ ); + } +); +ConnStatusOverlay.displayName = "ConnStatusOverlay"; diff --git a/frontend/app/element/markdown.tsx b/frontend/app/element/markdown.tsx index 7539aa3130..97ee3f9d68 100644 --- a/frontend/app/element/markdown.tsx +++ b/frontend/app/element/markdown.tsx @@ -216,7 +216,7 @@ interface WaveBlockProps { blockmap: Map; } -const WaveBlock: React.FC = (props) => { +function WaveBlock(props: WaveBlockProps) { const { blockkey, blockmap } = props; const block = blockmap.get(blockkey); if (block == null) { @@ -237,7 +237,7 @@ const WaveBlock: React.FC = (props) => { ); -}; +} const MarkdownImg = ({ props, diff --git a/frontend/app/suggestion/suggestion.tsx b/frontend/app/suggestion/suggestion.tsx index 597ca4b533..58af4be7a0 100644 --- a/frontend/app/suggestion/suggestion.tsx +++ b/frontend/app/suggestion/suggestion.tsx @@ -25,7 +25,7 @@ type BlockHeaderSuggestionControlProps = Omit; }; -const SuggestionControl: React.FC = ({ +function SuggestionControl({ anchorRef, isOpen, onClose, @@ -34,13 +34,13 @@ const SuggestionControl: React.FC = ({ fetchSuggestions, className, children, -}) => { +}: SuggestionControlProps) { if (!isOpen || !anchorRef.current || !fetchSuggestions) return null; return ( ); -}; +} function highlightPositions(target: string, positions: number[]): ReactNode[] { if (target == null) { @@ -84,7 +84,7 @@ function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): return [null, null]; } -const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion }) => { +function SuggestionIcon({ suggestion }: { suggestion: SuggestionType }) { if (suggestion.iconsrc) { return favicon; } @@ -110,11 +110,9 @@ const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion } } const iconClass = makeIconClass("file", true); return ; -}; +} -const SuggestionContent: React.FC<{ - suggestion: SuggestionType; -}> = ({ suggestion }) => { +function SuggestionContent({ suggestion }: { suggestion: SuggestionType }) { if (!isBlank(suggestion.subtext)) { return (
@@ -128,9 +126,9 @@ const SuggestionContent: React.FC<{ ); } return {highlightPositions(suggestion.display, suggestion.matchpos)}; -}; +} -const BlockHeaderSuggestionControl: React.FC = (props) => { +function BlockHeaderSuggestionControl(props: BlockHeaderSuggestionControlProps) { const [headerElem, setHeaderElem] = useState(null); const isOpen = useAtomValue(props.openAtom); @@ -145,31 +143,31 @@ const BlockHeaderSuggestionControl: React.FC const newClass = clsx(props.className, "rounded-t-none"); return ; -}; +} /** * The empty state component that can be used as a child of SuggestionControl. * If no children are provided to SuggestionControl, this default empty state will be used. */ -const SuggestionControlNoResults: React.FC<{ children?: React.ReactNode }> = ({ children }) => { +function SuggestionControlNoResults({ children }: { children?: React.ReactNode }) { return (
{children ?? No Suggestions}
); -}; +} -const SuggestionControlNoData: React.FC<{ children?: React.ReactNode }> = ({ children }) => { +function SuggestionControlNoData({ children }: { children?: React.ReactNode }) { return (
{children ?? No Suggestions}
); -}; +} interface SuggestionControlInnerProps extends Omit {} -const SuggestionControlInner: React.FC = ({ +function SuggestionControlInner({ anchorRef, onClose, onSelect, @@ -178,7 +176,7 @@ const SuggestionControlInner: React.FC = ({ className, placeholderText, children, -}) => { +}: SuggestionControlInnerProps) { const widgetId = useId(); const [query, setQuery] = useState(""); const reqNumRef = useRef(0); @@ -345,6 +343,6 @@ const SuggestionControlInner: React.FC = ({ ))}
); -}; +} export { BlockHeaderSuggestionControl, SuggestionControl, SuggestionControlNoData, SuggestionControlNoResults }; diff --git a/frontend/app/view/aifilediff/aifilediff.tsx b/frontend/app/view/aifilediff/aifilediff.tsx index 0d84b52fb3..dfd85f2917 100644 --- a/frontend/app/view/aifilediff/aifilediff.tsx +++ b/frontend/app/view/aifilediff/aifilediff.tsx @@ -51,7 +51,7 @@ export class AiFileDiffViewModel implements ViewModel { } } -const AiFileDiffView: React.FC> = ({ blockId, model }) => { +function AiFileDiffView({ blockId, model }: ViewComponentProps) { const blockData = jotai.useAtomValue(model.blockAtom); const diffData = jotai.useAtomValue(model.diffDataAtom); const error = jotai.useAtomValue(model.errorAtom); @@ -138,6 +138,6 @@ const AiFileDiffView: React.FC> = ({ blo fileName={diffData.fileName} /> ); -}; +} export default AiFileDiffView; diff --git a/frontend/app/view/launcher/launcher.tsx b/frontend/app/view/launcher/launcher.tsx index 6bcea92424..f71f272aa4 100644 --- a/frontend/app/view/launcher/launcher.tsx +++ b/frontend/app/view/launcher/launcher.tsx @@ -137,7 +137,7 @@ export class LauncherViewModel implements ViewModel { } } -const LauncherView: React.FC> = ({ blockId, model }) => { +function LauncherView({ blockId, model }: ViewComponentProps) { // Search and selection state const [searchTerm, setSearchTerm] = useAtom(model.searchTerm); const [selectedIndex, setSelectedIndex] = useAtom(model.selectedIndex); @@ -282,6 +282,6 @@ const LauncherView: React.FC> = ({ blockId ); -}; +} export default LauncherView; diff --git a/tsunami/frontend/src/element/modals.tsx b/tsunami/frontend/src/element/modals.tsx index 3550d8ebb6..f95e95e028 100644 --- a/tsunami/frontend/src/element/modals.tsx +++ b/tsunami/frontend/src/element/modals.tsx @@ -1,14 +1,14 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect } from "react"; +import { useEffect } from "react"; interface ModalProps { config: ModalConfig; onClose: (confirmed: boolean) => void; } -export const AlertModal: React.FC = ({ config, onClose }) => { +export function AlertModal({ config, onClose }: ModalProps) { const handleOk = () => { onClose(true); }; @@ -45,9 +45,9 @@ export const AlertModal: React.FC = ({ config, onClose }) => { ); -}; +} -export const ConfirmModal: React.FC = ({ config, onClose }) => { +export function ConfirmModal({ config, onClose }: ModalProps) { const handleConfirm = () => { onClose(true); }; @@ -94,4 +94,4 @@ export const ConfirmModal: React.FC = ({ config, onClose }) => { ); -}; +} From 2866909ab1c5dabbfd91b015e41ff4cccdd75c1b Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 30 Jan 2026 11:28:05 -0800 Subject: [PATCH 03/34] add config values/hierarchy for durable terminals --- docs/docs/config.mdx | 9 ++- frontend/types/gotypes.d.ts | 3 + pkg/jobcontroller/jobcontroller.go | 86 +++++++++++++++++++------ pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + pkg/wconfig/defaultconfig/settings.json | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 2 + schema/connections.json | 3 + schema/settings.json | 3 + 10 files changed, 89 insertions(+), 21 deletions(-) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 778fd1c1bf..fdd3151710 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -6,6 +6,7 @@ title: "Configuration" import { Kbd } from "@site/src/components/kbd"; import { PlatformProvider, PlatformSelectorButton } from "@site/src/components/platformcontext"; +import { VersionBadge } from "@site/src/components/versionbadge"; @@ -38,7 +39,7 @@ wsh editconfig | app:defaultnewblock | string | Sets the default new block (Cmd:n, Cmd:d). "term" for terminal block, "launcher" for launcher block (default = "term") | | app:showoverlayblocknums | bool | Set to false to disable the Ctrl+Shift block number overlay that appears when holding Ctrl+Shift (defaults to true) | | app:ctrlvpaste | bool | On Windows/Linux, when null (default) uses Control+V on Windows only. Set to true to force Control+V on all non-macOS platforms, false to disable the accelerator. macOS always uses Command+V regardless of this setting | -| app:confirmquit | bool | Set to false to disable the quit confirmation dialog when closing Wave Terminal (defaults to true, requires app restart) | +| app:confirmquit | bool | Set to false to disable the quit confirmation dialog when closing Wave Terminal (defaults to true, requires app restart) | | ai:preset | string | the default AI preset to use | | ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) | | ai:apitoken | string | your AI api token | @@ -63,8 +64,9 @@ wsh editconfig | term:allowbracketedpaste | bool | allow bracketed paste mode in terminal (default false) | | term:shiftenternewline | bool | when enabled, Shift+Enter sends escape sequence + newline (\u001b\n) instead of carriage return, useful for claude code and similar AI coding tools (default false) | | term:macoptionismeta | bool | on macOS, treat the Option key as Meta key for terminal keybindings (default false) | -| term:bellsound | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | -| term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default true) | +| term:bellsound | bool | when enabled, plays the system beep sound when the terminal bell (BEL character) is received (default false) | +| term:bellindicator | bool | when enabled, shows a visual indicator in the tab when the terminal bell is received (default true) | +| term:durable | bool | makes remote terminal sessions durable across network disconnects (defaults to true) | | editor:minimapenabled | bool | set to false to disable editor minimap | | editor:stickyscrollenabled | bool | enables monaco editor's stickyScroll feature (pinning headers of current context, e.g. class names, method names, etc.), defaults to false | | editor:wordwrap | bool | set to true to enable word wrapping in the editor (defaults to false) | @@ -133,6 +135,7 @@ For reference, this is the current default configuration (v0.11.5): "term:bellsound": false, "term:bellindicator": true, "term:copyonselect": true, + "term:durable": true, "waveai:showcloudmodes": true, "waveai:defaultmode": "waveai@balanced" } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a865f41313..30248d8e0e 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -741,6 +741,7 @@ declare global { "term:fontsize"?: number; "term:fontfamily"?: string; "term:theme"?: string; + "term:durable"?: boolean; "cmd:env"?: {[key: string]: string}; "cmd:initscript"?: string; "cmd:initscript.sh"?: string; @@ -1065,6 +1066,7 @@ declare global { "term:conndebug"?: string; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:durable"?: boolean; "web:zoom"?: number; "web:hidenav"?: boolean; "web:partition"?: string; @@ -1250,6 +1252,7 @@ declare global { "term:macoptionismeta"?: boolean; "term:bellsound"?: boolean; "term:bellindicator"?: boolean; + "term:durable"?: boolean; "editor:minimapenabled"?: boolean; "editor:stickyscrollenabled"?: boolean; "editor:wordwrap"?: boolean; diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index 224d715b15..1c50a65ef8 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -22,6 +22,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" @@ -51,21 +52,6 @@ const DefaultStreamRwnd = 64 * 1024 const MetaKey_TotalGap = "totalgap" const JobOutputFileName = "term" -func isJobManagerRunning(job *waveobj.Job) bool { - return job.JobManagerStatus == JobStatus_Running -} - -func GetJobManagerStatus(ctx context.Context, jobId string) (string, error) { - job, err := wstore.DBGet[*waveobj.Job](ctx, jobId) - if err != nil { - return "", fmt.Errorf("failed to get job: %w", err) - } - if job == nil { - return JobStatus_Done, nil - } - return job.JobManagerStatus, nil -} - type connState struct { actual bool processed bool @@ -78,6 +64,12 @@ type connStateManager struct { reconcileCh chan struct{} } +type jobState struct { + stateLock sync.Mutex + isConnecting bool + connectedStatus string +} + var ( jobConnStates = make(map[string]string) jobControllerLock sync.Mutex @@ -88,6 +80,21 @@ var ( } ) +func isJobManagerRunning(job *waveobj.Job) bool { + return job.JobManagerStatus == JobStatus_Running +} + +func GetJobManagerStatus(ctx context.Context, jobId string) (string, error) { + job, err := wstore.DBGet[*waveobj.Job](ctx, jobId) + if err != nil { + return "", fmt.Errorf("failed to get job: %w", err) + } + if job == nil { + return JobStatus_Done, nil + } + return job.JobManagerStatus, nil +} + func connReconcileWorker() { defer func() { panichandler.PanicHandler("jobcontroller:connReconcileWorker", recover()) @@ -729,10 +736,9 @@ func ReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts } log.Printf("[job:%s] route established, restarting streaming", jobId) - return RestartStreaming(ctx, jobId, true, rtOpts) + return restartStreaming(ctx, jobId, true, rtOpts) } - func ReconnectJobsForConn(ctx context.Context, connName string) error { isConnected, err := conncontroller.IsConnected(connName) if err != nil { @@ -766,7 +772,7 @@ func ReconnectJobsForConn(ctx context.Context, connName string) error { return nil } -func RestartStreaming(ctx context.Context, jobId string, knownConnected bool, rtOpts *waveobj.RuntimeOpts) error { +func restartStreaming(ctx context.Context, jobId string, knownConnected bool, rtOpts *waveobj.RuntimeOpts) error { job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) if err != nil { return fmt.Errorf("failed to get job: %w", err) @@ -911,6 +917,50 @@ func RestartStreaming(ctx context.Context, jobId string, knownConnected bool, rt return nil } +func IsBlockTermDurable(ctx context.Context, blockId string) (bool, error) { + block, err := wstore.DBGet[*waveobj.Block](ctx, blockId) + if err != nil { + return false, fmt.Errorf("failed to get block: %w", err) + } + if block == nil { + return false, fmt.Errorf("block not found: %s", blockId) + } + + // 1. Check if block has a JobId + if block.JobId != "" { + return true, nil + } + + // 2. Check if connection is local (local connections aren't durable) + connName := block.Meta.GetString(waveobj.MetaKey_Connection, "") + if conncontroller.IsLocalConnName(connName) { + return false, nil + } + + // 3. Check config hierarchy: blockmeta → connection → global (default true) + // Check block meta first + if val, exists := block.Meta["term:durable"]; exists { + if boolVal, ok := val.(bool); ok { + return boolVal, nil + } + } + // Check connection config + fullConfig := wconfig.GetWatcher().GetFullConfig() + if connName != "" { + if connConfig, exists := fullConfig.Connections[connName]; exists { + if connConfig.TermDurable != nil { + return *connConfig.TermDurable, nil + } + } + } + // Check global settings + if fullConfig.Settings.TermDurable != nil { + return *fullConfig.Settings.TermDurable, nil + } + // Default to true for non-local connections + return true, nil +} + func DeleteJob(ctx context.Context, jobId string) error { SetJobConnStatus(jobId, JobConnStatus_Disconnected) err := filestore.WFS.DeleteZone(ctx, jobId) diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index 4e99379ed2..c1383ee32c 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -119,6 +119,7 @@ const ( MetaKey_TermConnDebug = "term:conndebug" MetaKey_TermBellSound = "term:bellsound" MetaKey_TermBellIndicator = "term:bellindicator" + MetaKey_TermDurable = "term:durable" MetaKey_WebZoom = "web:zoom" MetaKey_WebHideNav = "web:hidenav" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index f46b0de497..73fcf52fd7 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -123,6 +123,7 @@ type MetaTSType struct { TermConnDebug string `json:"term:conndebug,omitempty"` // null, info, debug TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermDurable *bool `json:"term:durable,omitempty"` WebZoom float64 `json:"web:zoom,omitempty"` WebHideNav *bool `json:"web:hidenav,omitempty"` diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 0838e8d165..deffcc819a 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -27,6 +27,7 @@ "term:bellsound": false, "term:bellindicator": true, "term:copyonselect": true, + "term:durable": true, "waveai:showcloudmodes": true, "waveai:defaultmode": "waveai@balanced" } diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 98b9b2ab33..e79e9f0c17 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -50,6 +50,7 @@ const ( ConfigKey_TermMacOptionIsMeta = "term:macoptionismeta" ConfigKey_TermBellSound = "term:bellsound" ConfigKey_TermBellIndicator = "term:bellindicator" + ConfigKey_TermDurable = "term:durable" ConfigKey_EditorMinimapEnabled = "editor:minimapenabled" ConfigKey_EditorStickyScrollEnabled = "editor:stickyscrollenabled" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 0d392606b6..de5993c67a 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -97,6 +97,7 @@ type SettingsType struct { TermMacOptionIsMeta *bool `json:"term:macoptionismeta,omitempty"` TermBellSound *bool `json:"term:bellsound,omitempty"` TermBellIndicator *bool `json:"term:bellindicator,omitempty"` + TermDurable *bool `json:"term:durable,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` @@ -319,6 +320,7 @@ type ConnKeywords struct { TermFontSize float64 `json:"term:fontsize,omitempty"` TermFontFamily string `json:"term:fontfamily,omitempty"` TermTheme string `json:"term:theme,omitempty"` + TermDurable *bool `json:"term:durable,omitempty"` CmdEnv map[string]string `json:"cmd:env,omitempty"` CmdInitScript string `json:"cmd:initscript,omitempty"` diff --git a/schema/connections.json b/schema/connections.json index cdf3365d09..3cb33b5d55 100644 --- a/schema/connections.json +++ b/schema/connections.json @@ -36,6 +36,9 @@ "term:theme": { "type": "string" }, + "term:durable": { + "type": "boolean" + }, "cmd:env": { "additionalProperties": { "type": "string" diff --git a/schema/settings.json b/schema/settings.json index 1685b2bf17..dd159b7e09 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -128,6 +128,9 @@ "term:bellindicator": { "type": "boolean" }, + "term:durable": { + "type": "boolean" + }, "editor:minimapenabled": { "type": "boolean" }, From f35146005c789e0e00d455fcc0c96b60d2e0deb8 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 30 Jan 2026 11:29:17 -0800 Subject: [PATCH 04/34] add this controller analysis doc --- aiprompts/blockcontroller-lifecycle.md | 291 +++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 aiprompts/blockcontroller-lifecycle.md diff --git a/aiprompts/blockcontroller-lifecycle.md b/aiprompts/blockcontroller-lifecycle.md new file mode 100644 index 0000000000..4fa6e1b32f --- /dev/null +++ b/aiprompts/blockcontroller-lifecycle.md @@ -0,0 +1,291 @@ +# Block Controller Lifecycle + +## Overview + +Block controllers manage the execution lifecycle of terminal shells, commands, and other interactive processes. **The frontend drives the controller lifecycle** - the backend is reactive, creating and managing controllers in response to frontend requests. + +## Controller States + +Controllers have three primary states: +- **`init`** - Controller exists but process is not running +- **`running`** - Process is actively running +- **`done`** - Process has exited + +## Architecture Components + +### Backend: Controller Registry + +Location: [`pkg/blockcontroller/blockcontroller.go`](pkg/blockcontroller/blockcontroller.go) + +The backend maintains a **global controller registry** that maps blockIds to controller instances: + +```go +var ( + controllerRegistry = make(map[string]Controller) + registryLock sync.RWMutex +) +``` + +Controllers implement the [`Controller` interface](pkg/blockcontroller/blockcontroller.go:64): +- `Start(ctx, blockMeta, rtOpts, force)` - Start the controller process +- `Stop(graceful, newStatus)` - Stop the controller process +- `GetRuntimeStatus()` - Get current runtime status +- `SendInput(input)` - Send input (data, signals, terminal size) to the process + +### Frontend: View Model + +Location: [`frontend/app/view/term/term-model.ts`](frontend/app/view/term/term-model.ts) + +The [`TermViewModel`](frontend/app/view/term/term-model.ts:44) manages the frontend side of a terminal block: + +**Key Atoms:** +- `shellProcFullStatus` - Holds the current controller status from backend +- `shellProcStatus` - Derived atom for just the status string ("init", "running", "done") +- `isRestarting` - UI state for restart animation + +**Event Subscription:** +The constructor subscribes to controller status events (line 317-324): +```typescript +this.shellProcStatusUnsubFn = waveEventSubscribe({ + eventType: "controllerstatus", + scope: WOS.makeORef("block", blockId), + handler: (event) => { + let bcRTS: BlockControllerRuntimeStatus = event.data; + this.updateShellProcStatus(bcRTS); + }, +}); +``` + +This creates a **reactive data flow**: backend publishes status updates → frontend receives via WebSocket events → UI updates automatically via Jotai atoms. + +## Lifecycle Flow + +### 1. Frontend Triggers Controller Creation/Start + +**Entry Point:** [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) RPC endpoint + +The frontend calls this via [`RpcApi.ControllerResyncCommand`](frontend/app/view/term/term-model.ts:661) when: + +1. **Manual Restart** - User clicks restart button or presses Enter when process is done + - Triggered by [`forceRestartController()`](frontend/app/view/term/term-model.ts:652) + - Passes `forcerestart: true` flag + - Includes current terminal size (`termsize: { rows, cols }`) + +2. **Connection Status Changes** - Connection becomes available/unavailable + - Monitored by [`TermResyncHandler`](frontend/app/view/term/term.tsx:34) component + - Watches `connStatus` atom for changes + - Calls `termRef.current?.resyncController("resync handler")` + +3. **Block Meta Changes** - Configuration like controller type or connection changes + - Happens when block metadata is updated + - Backend detects changes and triggers resync + +### 2. Backend Processes Resync Request + +The [`ResyncController()`](pkg/blockcontroller/blockcontroller.go:120) function: + +```go +func ResyncController(ctx context.Context, tabId, blockId string, + rtOpts *waveobj.RuntimeOpts, force bool) error +``` + +**Steps:** + +1. **Get Block Data** - Fetch block metadata from database +2. **Determine Controller Type** - Read `controller` meta key ("shell", "cmd", "tsunami") +3. **Check Existing Controller:** + - If controller type changed → stop old, create new + - If connection changed (for shell/cmd) → stop and restart + - If `force=true` → stop existing +4. **Register Controller** - Add to registry (replaces existing if present) +5. **Check if Start Needed** - If status is "init" or "done": + - For remote connections: verify connection status first + - Call `controller.Start(ctx, blockMeta, rtOpts, force)` +6. **Publish Status** - Controller publishes runtime status updates + +**Important:** Registering a new controller automatically stops any existing controller for that blockId (line 95-98): +```go +if existingController != nil { + existingController.Stop(false, Status_Done) + wstore.DeleteRTInfo(waveobj.MakeORef(waveobj.OType_Block, blockId)) +} +``` + +### 3. Backend Publishes Status Updates + +Controllers publish their status via the event system when: +- Process starts +- Process state changes +- Process exits + +The status includes: +- `shellprocstatus` - "init", "running", or "done" +- `shellprocconnname` - Connection name being used +- `shellprocexitcode` - Exit code when done +- `version` - Incrementing version number for ordering + +### 4. Frontend Receives and Processes Updates + +**Status Update Handler** (line 321-323): +```typescript +handler: (event) => { + let bcRTS: BlockControllerRuntimeStatus = event.data; + this.updateShellProcStatus(bcRTS); +} +``` + +**Status Update Logic** (line 430-438): +```typescript +updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { + if (fullStatus == null) return; + const curStatus = globalStore.get(this.shellProcFullStatus); + // Only update if newer version + if (curStatus == null || curStatus.version < fullStatus.version) { + globalStore.set(this.shellProcFullStatus, fullStatus); + } +} +``` + +The version check ensures out-of-order events don't cause issues. + +### 5. UI Updates Reactively + +The UI reacts to status changes through Jotai atoms: + +**Header Buttons** (line 263-306): +- Show "Play" icon when status is "init" +- Show "Refresh" icon when status is "running" or "done" +- Display exit code/status icons for cmd controller + +**Restart Behavior** (line 631-635 in term.tsx via term-model.ts): +```typescript +const shellProcStatus = globalStore.get(this.shellProcStatus); +if ((shellProcStatus == "done" || shellProcStatus == "init") && + keyutil.checkKeyPressed(waveEvent, "Enter")) { + this.forceRestartController(); + return false; +} +``` + +Pressing Enter when the process is done/init triggers a restart. + +## Input Flow + +**Frontend → Backend:** + +When user types in terminal, data flows through [`sendDataToController()`](frontend/app/view/term/term-model.ts:408): +```typescript +sendDataToController(data: string) { + const b64data = stringToBase64(data); + RpcApi.ControllerInputCommand(TabRpcClient, { + blockid: this.blockId, + inputdata64: b64data + }); +} +``` + +This calls the backend [`SendInput()`](pkg/blockcontroller/blockcontroller.go:260) function which forwards to the controller's `SendInput()` method. + +The [`BlockInputUnion`](pkg/blockcontroller/blockcontroller.go:48) supports three types of input: +- `inputdata` - Raw terminal input bytes +- `signame` - Signal names (e.g., "SIGTERM", "SIGINT") +- `termsize` - Terminal size changes (rows/cols) + +## Key Design Principles + +### 1. Frontend-Driven Architecture + +The frontend has full control over controller lifecycle: +- **Creates** controllers by calling ResyncController +- **Restarts** controllers via forcerestart flag +- **Monitors** status via event subscriptions +- **Sends input** via ControllerInput RPC + +The backend is stateless and reactive - it doesn't make lifecycle decisions autonomously. + +### 2. Idempotent Resync + +`ResyncController()` is idempotent - calling it multiple times with the same state is safe: +- If controller exists and is running with correct type/connection → no-op +- If configuration changed → replaces controller +- If force flag set → always restarts + +This makes it safe to call on various triggers (connection change, focus, etc.). + +### 3. Versioned Status Updates + +Status includes a monotonically increasing version number: +- Frontend can process events out-of-order +- Only applies updates with newer versions +- Prevents race conditions from concurrent updates + +### 4. Automatic Cleanup + +When a controller is replaced: +- Old controller is automatically stopped +- Runtime info is cleaned up +- Registry entry is updated atomically + +The `registerController()` function handles this automatically (line 84-99). + +## Common Patterns + +### Restarting a Controller + +```typescript +// In term-model.ts +forceRestartController() { + this.triggerRestartAtom(); // UI feedback + const termsize = { + rows: this.termRef.current?.terminal?.rows, + cols: this.termRef.current?.terminal?.cols, + }; + RpcApi.ControllerResyncCommand(TabRpcClient, { + tabid: globalStore.get(atoms.staticTabId), + blockid: this.blockId, + forcerestart: true, + rtopts: { termsize: termsize }, + }); +} +``` + +### Handling Connection Changes + +```typescript +// In term.tsx - TermResyncHandler component +React.useEffect(() => { + const isConnected = connStatus?.status == "connected"; + const wasConnected = lastConnStatus?.status == "connected"; + if (isConnected == wasConnected && curConnName == lastConnName) { + return; // No change + } + model.termRef.current?.resyncController("resync handler"); + setLastConnStatus(connStatus); +}, [connStatus]); +``` + +### Monitoring Status + +```typescript +// Status is automatically available via atom +const shellProcStatus = jotai.useAtomValue(model.shellProcStatus); + +// Use in UI +if (shellProcStatus == "running") { + // Show running state +} else if (shellProcStatus == "done") { + // Show restart button +} +``` + +## Summary + +The block controller lifecycle is **frontend-driven and event-reactive**: + +1. **Frontend triggers** controller creation/restart via `ControllerResyncCommand` RPC +2. **Backend processes** the request in `ResyncController()`, creating/starting controllers as needed +3. **Backend publishes** status updates via WebSocket events +4. **Frontend receives** status updates and updates Jotai atoms +5. **UI reacts** automatically to atom changes via React components + +This architecture gives the frontend full control over when processes start/stop while keeping the backend focused on process management. The event-based status updates create a clean separation of concerns and enable real-time UI updates without polling. From 364de34b0a8ee721ef6b03945dbc1835341256af Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 30 Jan 2026 12:39:27 -0800 Subject: [PATCH 05/34] working on a more consistent is-durable defn for term blocks --- frontend/app/view/term/term-model.ts | 47 ++++++++ pkg/blockcontroller/blockcontroller.go | 18 ++- ...ontroller.go => durableshellcontroller.go} | 108 +++++++++--------- pkg/jobcontroller/jobcontroller.go | 32 +++--- pkg/remote/conncontroller/conncontroller.go | 4 + 5 files changed, 130 insertions(+), 79 deletions(-) rename pkg/blockcontroller/{shelljobcontroller.go => durableshellcontroller.go} (74%) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 7a606a82b8..0225deeec1 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -1099,3 +1099,50 @@ export function getAllBasicTermModels(): TermViewModel[] { } return termModels; } + +// this function must be kept up to date with IsBlockTermDurable in jobcontroller.go +export function isBlockTermDurable(block: Block): boolean { + if (block == null) { + return false; + } + + // Check if view is "term", and controller is "shell" + if (block.meta?.view != "term" && block.meta?.controller == "shell") { + return false; + } + + // 1. Check if block has a JobId + if (block.jobid != null && block.jobid != "") { + return true; + } + + // 2. Check if connection is local or WSL (not durable) + const connName = block.meta?.connection ?? ""; + // TODO: Need TypeScript equivalents of conncontroller.IsLocalConnName and IsWslConnName + // if (isLocalConnName(connName) || isWslConnName(connName)) { + // return false; + // } + + // 3. Check config hierarchy: blockmeta → connection → global (default true) + // Check block meta first + if (block.meta?.["term:durable"] != null) { + return block.meta["term:durable"]; + } + + // Check connection config + const fullConfig = globalStore.get(atoms.fullConfigAtom); + if (connName != "" && fullConfig?.connections?.[connName]) { + const connConfig = fullConfig.connections[connName]; + if (connConfig["term:durable"] != null) { + return connConfig["term:durable"]; + } + } + + // Check global settings + if (fullConfig?.settings?.["term:durable"] != null) { + return fullConfig.settings["term:durable"]; + } + + // Default to true for non-local connections + return true; +} diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 2d8e2661c9..4f424ea7a5 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/filestore" + "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/util/shellutil" @@ -142,12 +143,9 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts return nil } - // Determine if we should use ShellJobController vs ShellController - isPersistent := blockData.Meta.GetBool(waveobj.MetaKey_CmdPersistent, false) + // Determine if we should use DurableShellController vs ShellController connName := blockData.Meta.GetString(waveobj.MetaKey_Connection, "") - isRemote := !conncontroller.IsLocalConnName(connName) - isWSL := strings.HasPrefix(connName, "wsl://") - shouldUseShellJobController := isPersistent && isRemote && !isWSL && (controllerName == BlockController_Shell || controllerName == BlockController_Cmd) + shouldUseDurableShellController := jobcontroller.IsBlockTermDurable(blockData) && controllerName == BlockController_Shell // Check if we need to morph controller type if existing != nil { @@ -158,11 +156,11 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts case *ShellController: if controllerName != BlockController_Shell && controllerName != BlockController_Cmd { needsReplace = true - } else if shouldUseShellJobController { + } else if shouldUseDurableShellController { needsReplace = true } - case *ShellJobController: - if !shouldUseShellJobController { + case *DurableShellController: + if !shouldUseDurableShellController { needsReplace = true } case *TsunamiController: @@ -218,8 +216,8 @@ func ResyncController(ctx context.Context, tabId string, blockId string, rtOpts // Create new controller based on type switch controllerName { case BlockController_Shell, BlockController_Cmd: - if shouldUseShellJobController { - controller = MakeShellJobController(tabId, blockId, controllerName) + if shouldUseDurableShellController { + controller = MakeDurableShellController(tabId, blockId, controllerName) } else { controller = MakeShellController(tabId, blockId, controllerName) } diff --git a/pkg/blockcontroller/shelljobcontroller.go b/pkg/blockcontroller/durableshellcontroller.go similarity index 74% rename from pkg/blockcontroller/shelljobcontroller.go rename to pkg/blockcontroller/durableshellcontroller.go index 69cf72dda5..81f87fe9db 100644 --- a/pkg/blockcontroller/shelljobcontroller.go +++ b/pkg/blockcontroller/durableshellcontroller.go @@ -30,7 +30,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wstore" ) -type ShellJobController struct { +type DurableShellController struct { Lock *sync.Mutex ControllerType string @@ -46,8 +46,8 @@ type ShellJobController struct { LastKnownStatus string } -func MakeShellJobController(tabId string, blockId string, controllerType string) Controller { - return &ShellJobController{ +func MakeDurableShellController(tabId string, blockId string, controllerType string) Controller { + return &DurableShellController{ Lock: &sync.Mutex{}, ControllerType: controllerType, TabId: tabId, @@ -57,63 +57,63 @@ func MakeShellJobController(tabId string, blockId string, controllerType string) } } -func (sjc *ShellJobController) WithLock(f func()) { - sjc.Lock.Lock() - defer sjc.Lock.Unlock() +func (dsc *DurableShellController) WithLock(f func()) { + dsc.Lock.Lock() + defer dsc.Lock.Unlock() f() } -func (sjc *ShellJobController) getJobId() string { - sjc.Lock.Lock() - defer sjc.Lock.Unlock() - return sjc.JobId +func (dsc *DurableShellController) getJobId() string { + dsc.Lock.Lock() + defer dsc.Lock.Unlock() + return dsc.JobId } -func (sjc *ShellJobController) getNextInputSeq() (string, int) { - sjc.Lock.Lock() - defer sjc.Lock.Unlock() - sjc.inputSeqNum++ - return sjc.InputSessionId, sjc.inputSeqNum +func (dsc *DurableShellController) getNextInputSeq() (string, int) { + dsc.Lock.Lock() + defer dsc.Lock.Unlock() + dsc.inputSeqNum++ + return dsc.InputSessionId, dsc.inputSeqNum } -func (sjc *ShellJobController) getJobStatus_withlock() string { - if sjc.JobId == "" { - sjc.LastKnownStatus = Status_Init +func (dsc *DurableShellController) getJobStatus_withlock() string { + if dsc.JobId == "" { + dsc.LastKnownStatus = Status_Init return Status_Init } - status, err := jobcontroller.GetJobManagerStatus(context.Background(), sjc.JobId) + status, err := jobcontroller.GetJobManagerStatus(context.Background(), dsc.JobId) if err != nil { - log.Printf("error getting job status for %s: %v, using last known status: %s", sjc.JobId, err, sjc.LastKnownStatus) - return sjc.LastKnownStatus + log.Printf("error getting job status for %s: %v, using last known status: %s", dsc.JobId, err, dsc.LastKnownStatus) + return dsc.LastKnownStatus } - sjc.LastKnownStatus = status + dsc.LastKnownStatus = status return status } -func (sjc *ShellJobController) getRuntimeStatus_withlock() BlockControllerRuntimeStatus { +func (dsc *DurableShellController) getRuntimeStatus_withlock() BlockControllerRuntimeStatus { var rtn BlockControllerRuntimeStatus - rtn.Version = sjc.VersionTs.GetVersionTs() - rtn.BlockId = sjc.BlockId - rtn.ShellProcStatus = sjc.getJobStatus_withlock() + rtn.Version = dsc.VersionTs.GetVersionTs() + rtn.BlockId = dsc.BlockId + rtn.ShellProcStatus = dsc.getJobStatus_withlock() return rtn } -func (sjc *ShellJobController) GetRuntimeStatus() *BlockControllerRuntimeStatus { +func (dsc *DurableShellController) GetRuntimeStatus() *BlockControllerRuntimeStatus { var rtn BlockControllerRuntimeStatus - sjc.WithLock(func() { - rtn = sjc.getRuntimeStatus_withlock() + dsc.WithLock(func() { + rtn = dsc.getRuntimeStatus_withlock() }) return &rtn } -func (sjc *ShellJobController) sendUpdate_withlock() { - rtStatus := sjc.getRuntimeStatus_withlock() +func (dsc *DurableShellController) sendUpdate_withlock() { + rtStatus := dsc.getRuntimeStatus_withlock() log.Printf("sending blockcontroller update %#v\n", rtStatus) wps.Broker.Publish(wps.WaveEvent{ Event: wps.Event_ControllerStatus, Scopes: []string{ - waveobj.MakeORef(waveobj.OType_Tab, sjc.TabId).String(), - waveobj.MakeORef(waveobj.OType_Block, sjc.BlockId).String(), + waveobj.MakeORef(waveobj.OType_Tab, dsc.TabId).String(), + waveobj.MakeORef(waveobj.OType_Block, dsc.BlockId).String(), }, Data: rtStatus, }) @@ -128,8 +128,8 @@ func (sjc *ShellJobController) sendUpdate_withlock() { // - force=false: returns without starting (leaves block unstarted) // // After establishing jobId, ensures job connection is active (reconnects if needed) -func (sjc *ShellJobController) Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error { - blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, sjc.BlockId) +func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj.MetaMapType, rtOpts *waveobj.RuntimeOpts, force bool) error { + blockData, err := wstore.DBMustGet[*waveobj.Block](ctx, dsc.BlockId) if err != nil { return fmt.Errorf("error getting block: %w", err) } @@ -147,10 +147,10 @@ func (sjc *ShellJobController) Start(ctx context.Context, blockMeta waveobj.Meta } if status != jobcontroller.JobStatus_Running { if force { - log.Printf("block %q has jobId %s but manager is not running (status: %s), detaching (force=true)\n", sjc.BlockId, blockData.JobId, status) + log.Printf("block %q has jobId %s but manager is not running (status: %s), detaching (force=true)\n", dsc.BlockId, blockData.JobId, status) jobcontroller.DetachJobFromBlock(ctx, blockData.JobId, false) } else { - log.Printf("block %q has jobId %s but manager is not running (status: %s), not starting (force=false)\n", sjc.BlockId, blockData.JobId, status) + log.Printf("block %q has jobId %s but manager is not running (status: %s), not starting (force=false)\n", dsc.BlockId, blockData.JobId, status) return nil } } else { @@ -159,22 +159,22 @@ func (sjc *ShellJobController) Start(ctx context.Context, blockMeta waveobj.Meta } if jobId == "" { - log.Printf("block %q starting new shell job\n", sjc.BlockId) - newJobId, err := sjc.startNewJob(ctx, blockMeta, connName) + log.Printf("block %q starting new shell job\n", dsc.BlockId) + newJobId, err := dsc.startNewJob(ctx, blockMeta, connName) if err != nil { return fmt.Errorf("failed to start new job: %w", err) } jobId = newJobId - err = jobcontroller.AttachJobToBlock(ctx, jobId, sjc.BlockId) + err = jobcontroller.AttachJobToBlock(ctx, jobId, dsc.BlockId) if err != nil { log.Printf("error attaching job to block: %v\n", err) } } - sjc.WithLock(func() { - sjc.JobId = jobId - sjc.sendUpdate_withlock() + dsc.WithLock(func() { + dsc.JobId = jobId + dsc.sendUpdate_withlock() }) _, err = jobcontroller.CheckJobConnected(ctx, jobId) @@ -189,11 +189,11 @@ func (sjc *ShellJobController) Start(ctx context.Context, blockMeta waveobj.Meta return nil } -func (sjc *ShellJobController) Stop(graceful bool, newStatus string, destroy bool) { +func (dsc *DurableShellController) Stop(graceful bool, newStatus string, destroy bool) { if !destroy { return } - jobId := sjc.getJobId() + jobId := dsc.getJobId() if jobId == "" { return } @@ -209,15 +209,15 @@ func (sjc *ShellJobController) Stop(graceful bool, newStatus string, destroy boo } } -func (sjc *ShellJobController) SendInput(inputUnion *BlockInputUnion) error { +func (dsc *DurableShellController) SendInput(inputUnion *BlockInputUnion) error { if inputUnion == nil { return nil } - jobId := sjc.getJobId() + jobId := dsc.getJobId() if jobId == "" { return fmt.Errorf("no job attached to controller") } - inputSessionId, seqNum := sjc.getNextInputSeq() + inputSessionId, seqNum := dsc.getNextInputSeq() data := wshrpc.CommandJobInputData{ JobId: jobId, InputSessionId: inputSessionId, @@ -231,7 +231,7 @@ func (sjc *ShellJobController) SendInput(inputUnion *BlockInputUnion) error { return jobcontroller.SendInput(context.Background(), data) } -func (sjc *ShellJobController) startNewJob(ctx context.Context, blockMeta waveobj.MetaMapType, connName string) (string, error) { +func (dsc *DurableShellController) startNewJob(ctx context.Context, blockMeta waveobj.MetaMapType, connName string) (string, error) { termSize := waveobj.TermSize{ Rows: shellutil.DefaultTermRows, Cols: shellutil.DefaultTermCols, @@ -252,12 +252,12 @@ func (sjc *ShellJobController) startNewJob(ctx context.Context, blockMeta waveob return "", fmt.Errorf("unable to obtain remote info from connserver: %w", err) } shellType := shellutil.GetShellTypeFromShellPath(remoteInfo.Shell) - swapToken := makeSwapToken(ctx, ctx, sjc.BlockId, blockMeta, connName, shellType) + swapToken := makeSwapToken(ctx, ctx, dsc.BlockId, blockMeta, connName, shellType) sockName := wavebase.GetPersistentRemoteSockName(wstore.GetClientId()) rpcContext := wshrpc.RpcContext{ ProcRoute: true, SockName: sockName, - BlockId: sjc.BlockId, + BlockId: dsc.BlockId, Conn: connName, } jwtStr, err := wshutil.MakeClientJWTToken(rpcContext) @@ -280,13 +280,13 @@ func (sjc *ShellJobController) startNewJob(ctx context.Context, blockMeta waveob return jobId, nil } -func (sjc *ShellJobController) resetTerminalState(logCtx context.Context) { +func (dsc *DurableShellController) resetTerminalState(logCtx context.Context) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() jobId := "" - sjc.WithLock(func() { - jobId = sjc.JobId + dsc.WithLock(func() { + jobId = dsc.JobId }) if jobId == "" { return diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index 1c50a65ef8..7defdb371e 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -917,31 +917,33 @@ func restartStreaming(ctx context.Context, jobId string, knownConnected bool, rt return nil } -func IsBlockTermDurable(ctx context.Context, blockId string) (bool, error) { - block, err := wstore.DBGet[*waveobj.Block](ctx, blockId) - if err != nil { - return false, fmt.Errorf("failed to get block: %w", err) - } +// this function must be kept up to date with the isBlockTermDurable function in term-model.ts +func IsBlockTermDurable(block *waveobj.Block) bool { if block == nil { - return false, fmt.Errorf("block not found: %s", blockId) + return false + } + + // Check if view is "term", and controller is "shell" + if block.Meta.GetString(waveobj.MetaKey_View, "") != "term" && block.Meta.GetString(waveobj.MetaKey_Controller, "") == "shell" { + return false } // 1. Check if block has a JobId if block.JobId != "" { - return true, nil + return true } - // 2. Check if connection is local (local connections aren't durable) + // 2. Check if connection is local or WSL (not durable) connName := block.Meta.GetString(waveobj.MetaKey_Connection, "") - if conncontroller.IsLocalConnName(connName) { - return false, nil + if conncontroller.IsLocalConnName(connName) || conncontroller.IsWslConnName(connName) { + return false } // 3. Check config hierarchy: blockmeta → connection → global (default true) // Check block meta first - if val, exists := block.Meta["term:durable"]; exists { + if val, exists := block.Meta[waveobj.MetaKey_TermDurable]; exists { if boolVal, ok := val.(bool); ok { - return boolVal, nil + return boolVal } } // Check connection config @@ -949,16 +951,16 @@ func IsBlockTermDurable(ctx context.Context, blockId string) (bool, error) { if connName != "" { if connConfig, exists := fullConfig.Connections[connName]; exists { if connConfig.TermDurable != nil { - return *connConfig.TermDurable, nil + return *connConfig.TermDurable } } } // Check global settings if fullConfig.Settings.TermDurable != nil { - return *fullConfig.Settings.TermDurable, nil + return *fullConfig.Settings.TermDurable } // Default to true for non-local connections - return true, nil + return true } func DeleteJob(ctx context.Context, jobId string) error { diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index 18e56d183e..4e6f0aeefd 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -96,6 +96,10 @@ func IsLocalConnName(connName string) bool { return strings.HasPrefix(connName, "local:") || connName == "local" || connName == "" } +func IsWslConnName(connName string) bool { + return strings.HasPrefix(connName, "wsl://") +} + func GetAllConnStatus() []wshrpc.ConnStatus { globalLock.Lock() defer globalLock.Unlock() From 6b6a8794433e1356d8ad540bf225afe288b6f9fa Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 30 Jan 2026 13:05:30 -0800 Subject: [PATCH 06/34] implement `wsh focusblock` --- cmd/wsh/cmd/wshcmd-focusblock.go | 51 ++++++++++++++++++++++++++++++ frontend/app/store/tabrpcclient.ts | 14 ++++++++ frontend/app/store/wshclientapi.ts | 5 +++ pkg/wshrpc/wshclient/wshclient.go | 6 ++++ pkg/wshrpc/wshrpctypes.go | 3 ++ 5 files changed, 79 insertions(+) create mode 100644 cmd/wsh/cmd/wshcmd-focusblock.go diff --git a/cmd/wsh/cmd/wshcmd-focusblock.go b/cmd/wsh/cmd/wshcmd-focusblock.go new file mode 100644 index 0000000000..3f6603a3e2 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-focusblock.go @@ -0,0 +1,51 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" +) + +var focusBlockCmd = &cobra.Command{ + Use: "focusblock [-b {blockid|blocknum|this}]", + Short: "focus a block in the current tab", + Args: cobra.NoArgs, + RunE: focusBlockRun, + PreRunE: preRunSetupRpcClient, +} + +func init() { + rootCmd.AddCommand(focusBlockCmd) +} + +func focusBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("focusblock", rtnErr == nil) + }() + + tabId := os.Getenv("WAVETERM_TABID") + if tabId == "" { + return fmt.Errorf("no tab id specified (set WAVETERM_TABID environment variable)") + } + + fullORef, err := resolveBlockArg() + if err != nil { + return err + } + + route := fmt.Sprintf("tab:%s", tabId) + err = wshclient.SetBlockFocusCommand(RpcClient, fullORef.OID, &wshrpc.RpcOpts{ + Route: route, + Timeout: 2000, + }) + if err != nil { + return fmt.Errorf("focusing block: %v", err) + } + return nil +} diff --git a/frontend/app/store/tabrpcclient.ts b/frontend/app/store/tabrpcclient.ts index caab82ac82..3e7ad7fd76 100644 --- a/frontend/app/store/tabrpcclient.ts +++ b/frontend/app/store/tabrpcclient.ts @@ -89,4 +89,18 @@ export class TabClient extends WshClient { await model.handleSubmit(); } } + + async handle_setblockfocus(rh: RpcResponseHelper, blockId: string): Promise { + const layoutModel = getLayoutModelForStaticTab(); + if (!layoutModel) { + throw new Error("Layout model not found"); + } + + const node = layoutModel.getNodeByBlockId(blockId); + if (!node) { + throw new Error(`Block not found in tab: ${blockId}`); + } + + layoutModel.focusNode(node.id); + } } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index bd0e5405eb..8fc1aa4f08 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -637,6 +637,11 @@ class RpcApiType { return client.wshRpcCall("sendtelemetry", null, opts); } + // command "setblockfocus" [call] + SetBlockFocusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("setblockfocus", data, opts); + } + // command "setconfig" [call] SetConfigCommand(client: WshClient, data: SettingsType, opts?: RpcOpts): Promise { return client.wshRpcCall("setconfig", data, opts); diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index afc7b59dca..fe1f4b5f97 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -767,6 +767,12 @@ func SendTelemetryCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) error { return err } +// command "setblockfocus", wshserver.SetBlockFocusCommand +func SetBlockFocusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "setblockfocus", data, opts) + return err +} + // command "setconfig", wshserver.SetConfigCommand func SetConfigCommand(w *wshutil.WshRpc, data wshrpc.MetaSettingsType, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "setconfig", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index b8cb8ecbbf..001741a7b9 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -151,6 +151,9 @@ type WshRpcInterface interface { // screenshot CaptureBlockScreenshotCommand(ctx context.Context, data CommandCaptureBlockScreenshotData) (string, error) + // block focus + SetBlockFocusCommand(ctx context.Context, blockId string) error + // rtinfo GetRTInfoCommand(ctx context.Context, data CommandGetRTInfoData) (*waveobj.ObjRTInfo, error) SetRTInfoCommand(ctx context.Context, data CommandSetRTInfoData) error From 8433bfd104abd80dd3c0684d4fb47226586030d8 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 30 Jan 2026 13:16:06 -0800 Subject: [PATCH 07/34] fix (premium) text always showing up --- frontend/app/aipanel/aimode.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/app/aipanel/aimode.tsx b/frontend/app/aipanel/aimode.tsx index 9848c2327d..3602cdd360 100644 --- a/frontend/app/aipanel/aimode.tsx +++ b/frontend/app/aipanel/aimode.tsx @@ -15,12 +15,13 @@ interface AIModeMenuItemProps { config: AIModeConfigWithMode; isSelected: boolean; isDisabled: boolean; + isPremiumDisabled: boolean; onClick: () => void; isFirst?: boolean; isLast?: boolean; } -const AIModeMenuItem = memo(({ config, isSelected, isDisabled, onClick, isFirst, isLast }: AIModeMenuItemProps) => { +const AIModeMenuItem = memo(({ config, isSelected, isDisabled, isPremiumDisabled, onClick, isFirst, isLast }: AIModeMenuItemProps) => { return (
- ); - } else if (elem.elemtype == "div") { - return ( -
- {elem.children.map((child, childIdx) => ( - - ))} -
- ); - } else if (elem.elemtype == "menubutton") { - return ; - } - return null; -}); - -function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): React.ReactElement[] { - const headerTextElems: React.ReactElement[] = []; - for (let idx = 0; idx < headerTextUnion.length; idx++) { - const elem = headerTextUnion[idx]; - const renderedElement = ; - if (renderedElement) { - headerTextElems.push(renderedElement); - } - } - return headerTextElems; -} - const BlockMask = React.memo(({ nodeModel }: { nodeModel: NodeModel }) => { const tabModel = useTabModel(); const isFocused = jotai.useAtomValue(nodeModel.isFocused); @@ -525,4 +213,4 @@ const BlockFrame = React.memo((props: BlockFrameProps) => { return ; }); -export { BlockFrame, NumActiveConnColors }; +export { BlockFrame }; diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 2deba1a8f4..542f9f352a 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -1,15 +1,16 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { NumActiveConnColors } from "@/app/block/blockframe"; -import { getConnStatusAtom, recordTEvent } from "@/app/store/global"; +import { Button } from "@/app/element/button"; +import { IconButton, ToggleIconButton } from "@/element/iconbutton"; +import { MagnifyIcon } from "@/element/magnify"; +import { MenuButton } from "@/element/menubutton"; import * as util from "@/util/util"; import clsx from "clsx"; -import * as jotai from "jotai"; import * as React from "react"; -import DotsSvg from "../asset/dots-anim-4.svg"; export const colorRegex = /^((#[0-9a-f]{6,8})|([a-z]+))$/; +export const NumActiveConnColors = 8; export function blockViewToIcon(view: string): string { if (view == "term") { @@ -142,114 +143,24 @@ export function getBlockHeaderIcon(blockIcon: string, blockData: Block): React.R return blockIconElem; } -interface ConnectionButtonProps { - connection: string; - changeConnModalAtom: jotai.PrimitiveAtom; -} - -export function computeConnColorNum(connStatus: ConnStatus): number { - // activeconnnum is 1-indexed, so we need to adjust for when mod is 0 - const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors; - if (connColorNum == 0) { - return NumActiveConnColors; +export function getViewIconElem( + viewIconUnion: string | IconButtonDecl, + blockData: Block, + iconColor?: string +): React.ReactElement { + if (viewIconUnion == null || typeof viewIconUnion === "string") { + const viewIcon = viewIconUnion as string; + const style: React.CSSProperties = iconColor ? { color: iconColor, opacity: 1.0 } : {}; + return ( +
+ {getBlockHeaderIcon(viewIcon, blockData)} +
+ ); + } else { + return ; } - return connColorNum; } -export const ConnectionButton = React.memo( - React.forwardRef( - ({ connection, changeConnModalAtom }: ConnectionButtonProps, ref) => { - const [connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom); - const isLocal = util.isLocalConnName(connection); - const connStatusAtom = getConnStatusAtom(connection); - const connStatus = jotai.useAtomValue(connStatusAtom); - let showDisconnectedSlash = false; - let connIconElem: React.ReactNode = null; - const connColorNum = computeConnColorNum(connStatus); - let color = `var(--conn-icon-color-${connColorNum})`; - const clickHandler = function () { - recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "mouse" }); - setConnModalOpen(true); - }; - let titleText = null; - let shouldSpin = false; - let connDisplayName: string = null; - if (isLocal) { - color = "var(--grey-text-color)"; - if (connection === "local:gitbash") { - titleText = "Connected to Git Bash"; - connDisplayName = "Git Bash"; - } else { - titleText = "Connected to Local Machine"; - } - connIconElem = ( - - ); - } else { - titleText = "Connected to " + connection; - let iconName = "arrow-right-arrow-left"; - let iconSvg = null; - if (connStatus?.status == "connecting") { - color = "var(--warning-color)"; - titleText = "Connecting to " + connection; - shouldSpin = false; - iconSvg = ( -
- -
- ); - } else if (connStatus?.status == "error") { - color = "var(--error-color)"; - titleText = "Error connecting to " + connection; - if (connStatus?.error != null) { - titleText += " (" + connStatus.error + ")"; - } - showDisconnectedSlash = true; - } else if (!connStatus?.connected) { - color = "var(--grey-text-color)"; - titleText = "Disconnected from " + connection; - showDisconnectedSlash = true; - } - if (iconSvg != null) { - connIconElem = iconSvg; - } else { - connIconElem = ( - - ); - } - } - - return ( -
- - {connIconElem} - - - {connDisplayName ? ( -
{connDisplayName}
- ) : isLocal ? null : ( -
{connection}
- )} -
- ); - } - ) -); - export const Input = React.memo( ({ decl, className, preview }: { decl: HeaderInput; className: string; preview: boolean }) => { const { value, ref, isDisabled, onChange, onKeyDown, onFocus, onBlur } = decl; @@ -274,3 +185,75 @@ export const Input = React.memo( ); } ); + +export const OptMagnifyButton = React.memo( + ({ magnified, toggleMagnify, disabled }: { magnified: boolean; toggleMagnify: () => void; disabled: boolean }) => { + const magnifyDecl: IconButtonDecl = { + elemtype: "iconbutton", + icon: , + title: magnified ? "Minimize" : "Magnify", + click: toggleMagnify, + disabled, + }; + return ; + } +); + +export const HeaderTextElem = React.memo(({ elem, preview }: { elem: HeaderElem; preview: boolean }) => { + if (elem.elemtype == "iconbutton") { + return ; + } else if (elem.elemtype == "toggleiconbutton") { + return ; + } else if (elem.elemtype == "input") { + return ; + } else if (elem.elemtype == "text") { + return ( +
+ elem?.onClick(e)}> + ‎{elem.text} + +
+ ); + } else if (elem.elemtype == "textbutton") { + return ( + + ); + } else if (elem.elemtype == "div") { + return ( +
+ {elem.children.map((child, childIdx) => ( + + ))} +
+ ); + } else if (elem.elemtype == "menubutton") { + return ; + } + return null; +}); + +export function renderHeaderElements(headerTextUnion: HeaderElem[], preview: boolean): React.ReactElement[] { + const headerTextElems: React.ReactElement[] = []; + for (let idx = 0; idx < headerTextUnion.length; idx++) { + const elem = headerTextUnion[idx]; + const renderedElement = ; + if (renderedElement) { + headerTextElems.push(renderedElement); + } + } + return headerTextElems; +} + +export function computeConnColorNum(connStatus: ConnStatus): number { + const connColorNum = (connStatus?.activeconnnum ?? 1) % NumActiveConnColors; + if (connColorNum == 0) { + return NumActiveConnColors; + } + return connColorNum; +} diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx new file mode 100644 index 0000000000..fecd3ff082 --- /dev/null +++ b/frontend/app/block/connectionbutton.tsx @@ -0,0 +1,127 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { computeConnColorNum } from "@/app/block/blockutil"; +import { getConnStatusAtom, recordTEvent } from "@/app/store/global"; +import { IconButton } from "@/element/iconbutton"; +import * as util from "@/util/util"; +import clsx from "clsx"; +import * as jotai from "jotai"; +import * as React from "react"; +import DotsSvg from "../asset/dots-anim-4.svg"; + +interface ConnectionButtonProps { + connection: string; + changeConnModalAtom: jotai.PrimitiveAtom; + isTerminalBlock?: boolean; +} + +export const ConnectionButton = React.memo( + React.forwardRef( + ({ connection, changeConnModalAtom, isTerminalBlock }: ConnectionButtonProps, ref) => { + const [connModalOpen, setConnModalOpen] = jotai.useAtom(changeConnModalAtom); + const isLocal = util.isLocalConnName(connection); + const connStatusAtom = getConnStatusAtom(connection); + const connStatus = jotai.useAtomValue(connStatusAtom); + let showDisconnectedSlash = false; + let connIconElem: React.ReactNode = null; + const connColorNum = computeConnColorNum(connStatus); + let color = `var(--conn-icon-color-${connColorNum})`; + const clickHandler = function () { + recordTEvent("action:other", { "action:type": "conndropdown", "action:initiator": "mouse" }); + setConnModalOpen(true); + }; + let titleText = null; + let shouldSpin = false; + let connDisplayName: string = null; + if (isLocal) { + color = "var(--grey-text-color)"; + if (connection === "local:gitbash") { + titleText = "Connected to Git Bash"; + connDisplayName = "Git Bash"; + } else { + titleText = "Connected to Local Machine"; + } + connIconElem = ( + + ); + } else { + titleText = "Connected to " + connection; + let iconName = "arrow-right-arrow-left"; + let iconSvg = null; + if (connStatus?.status == "connecting") { + color = "var(--warning-color)"; + titleText = "Connecting to " + connection; + shouldSpin = false; + iconSvg = ( +
+ +
+ ); + } else if (connStatus?.status == "error") { + color = "var(--error-color)"; + titleText = "Error connecting to " + connection; + if (connStatus?.error != null) { + titleText += " (" + connStatus.error + ")"; + } + showDisconnectedSlash = true; + } else if (!connStatus?.connected) { + color = "var(--grey-text-color)"; + titleText = "Disconnected from " + connection; + showDisconnectedSlash = true; + } + if (iconSvg != null) { + connIconElem = iconSvg; + } else { + connIconElem = ( + + ); + } + } + + const wshProblem = connection && !connStatus?.wshenabled && connStatus?.status == "connected"; + const showNoWshButton = wshProblem && !isLocal; + + return ( + <> +
+ + {connIconElem} + + + {connDisplayName ? ( +
{connDisplayName}
+ ) : isLocal ? null : ( +
{connection}
+ )} +
+ {showNoWshButton && ( + + )} + + ); + } + ) +); +ConnectionButton.displayName = "ConnectionButton"; diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a6411c6706..1eb43b5c85 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; @@ -58,6 +58,7 @@ export class TermViewModel implements ViewModel { manageConnection: jotai.Atom; filterOutNowsh?: jotai.Atom; connStatus: jotai.Atom; + useTermHeader: jotai.Atom; termWshClient: TermWshClient; vdomBlockId: jotai.Atom; vdomToolbarBlockId: jotai.Atom; @@ -74,6 +75,7 @@ export class TermViewModel implements ViewModel { termBPMUnsubFn: () => void; isCmdController: jotai.Atom; isRestarting: jotai.PrimitiveAtom; + termDurableStatus: jotai.Atom<"connected" | "connecting" | "running" | "gone">; searchAtoms?: SearchAtoms; constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { @@ -103,10 +105,6 @@ export class TermViewModel implements ViewModel { if (termMode == "vdom") { return { elemtype: "iconbutton", icon: "bolt" }; } - const isDurable = get(getBlockTermDurableAtom(this.blockId)); - if (isDurable) { - return { elemtype: "iconbutton", icon: "shield", title: "Durable Session" }; - } const isCmd = get(this.isCmdController); if (isCmd) { } @@ -121,7 +119,7 @@ export class TermViewModel implements ViewModel { if (blockData?.meta?.controller == "cmd") { return ""; } - return "Terminal"; + return ""; }); this.viewText = jotai.atom((get) => { const termMode = get(this.termMode); @@ -220,6 +218,17 @@ export class TermViewModel implements ViewModel { } return true; }); + this.useTermHeader = jotai.atom((get) => { + const termMode = get(this.termMode); + if (termMode == "vdom") { + return false; + } + const isCmd = get(this.isCmdController); + if (isCmd) { + return false; + } + return true; + }); this.filterOutNowsh = jotai.atom(false); this.termBPMAtom = getOverrideConfigAtom(blockId, "term:allowbracketedpaste"); this.termThemeNameAtom = useBlockAtom(blockId, "termthemeatom", () => { @@ -331,6 +340,13 @@ export class TermViewModel implements ViewModel { const fullStatus = get(this.shellProcFullStatus); return fullStatus?.shellprocstatus ?? "init"; }); + this.termDurableStatus = jotai.atom((get) => { + const isDurable = get(getBlockTermDurableAtom(this.blockId)); + if (isDurable) { + return "running"; + } + return null; + }); this.termBPMUnsubFn = globalStore.sub(this.termBPMAtom, () => { if (this.termRef.current?.terminal) { const allowBPM = globalStore.get(this.termBPMAtom) ?? true; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index dd0a281908..1192e40517 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { type Placement } from "@floating-ui/react"; @@ -298,6 +298,8 @@ declare global { // The type of view, used for identifying and rendering the appropriate component. viewType: string; + useTermHeader?: jotai.Atom; + // Icon representing the view, can be a string or an IconButton declaration. viewIcon?: jotai.Atom; @@ -307,6 +309,8 @@ declare global { // Optional header text or elements for the view. viewText?: jotai.Atom; + termDurableStatus?: jotai.Atom<"connected" | "connecting" | "running" | "gone">; + // Icon button displayed before the title in the header. preIconButton?: jotai.Atom; From 74b5372ebe7ed8b8a532dd3f28038c7564aaa1af Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 30 Jan 2026 22:18:07 -0800 Subject: [PATCH 10/34] add localhost name --- docs/docs/config.mdx | 1 - frontend/app/block/blockframe-header.tsx | 28 +++++++++----- frontend/app/block/connectionbutton.tsx | 47 ++++++++++++++---------- frontend/types/gotypes.d.ts | 2 - pkg/wconfig/metaconsts.go | 3 -- pkg/wconfig/settingsconfig.go | 3 -- schema/settings.json | 6 --- 7 files changed, 46 insertions(+), 44 deletions(-) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index fdd3151710..d88bae615f 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -78,7 +78,6 @@ wsh editconfig | web:openlinksinternally | bool | set to false to open web links in external browser | | web:defaulturl | string | default web page to open in the web widget when no url is provided (homepage) | | web:defaultsearch | string | search template for web searches. e.g. `https://www.google.com/search?q={query}`. "\{query}" gets replaced by search term | -| blockheader:showblockids | bool | show first 8 chars of blockid in the header | | autoupdate:enabled | bool | enable/disable checking for updates (requires app restart) | | autoupdate:intervalms | float64 | time in milliseconds to wait between update checks (requires app restart) | | autoupdate:installonquit | bool | whether to automatically install updates on quit (requires app restart) | diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index e8b8bb5353..e9e90b5bee 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -10,7 +10,7 @@ import { } from "@/app/block/blockutil"; import { ConnectionButton } from "@/app/block/connectionbutton"; import { ContextMenuModel } from "@/app/store/contextmenu"; -import { getSettingsKeyAtom, recordTEvent, WOS } from "@/app/store/global"; +import { recordTEvent, WOS } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { uxCloseBlock } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; @@ -18,6 +18,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { IconButton } from "@/element/iconbutton"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; +import { cn } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import { BlockFrameProps } from "./blocktypes"; @@ -167,9 +168,10 @@ const BlockFrame_Header = ({ }: BlockFrameProps & { changeConnModalAtom: jotai.PrimitiveAtom; error?: Error }) => { const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); let viewName = util.useAtomValueSafe(viewModel?.viewName) ?? blockViewToName(blockData?.meta?.view); - const showBlockIds = jotai.useAtomValue(getSettingsKeyAtom("blockheader:showblockids")); let viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); + const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader); + const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus); const magnified = jotai.useAtomValue(nodeModel.isMagnified); const prevMagifiedState = React.useRef(magnified); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); @@ -192,17 +194,20 @@ const BlockFrame_Header = ({ return (
handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel)} > - {preIconButton && } -
- {viewIconElem} - {viewName &&
{viewName}
} - {showBlockIds &&
[{nodeModel.blockId.substring(0, 8)}]
} -
+ {!useTermHeader && ( + <> + {preIconButton && } +
+ {viewIconElem} + {viewName &&
{viewName}
} +
+ + )} {manageConnection && ( )} + {useTermHeader && termDurableStatus != null && ( +
+ +
+ )}
diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx index fecd3ff082..71cbbdb6d0 100644 --- a/frontend/app/block/connectionbutton.tsx +++ b/frontend/app/block/connectionbutton.tsx @@ -2,10 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { computeConnColorNum } from "@/app/block/blockutil"; -import { getConnStatusAtom, recordTEvent } from "@/app/store/global"; +import { getConnStatusAtom, getHostName, getUserName, recordTEvent } from "@/app/store/global"; import { IconButton } from "@/element/iconbutton"; import * as util from "@/util/util"; -import clsx from "clsx"; import * as jotai from "jotai"; import * as React from "react"; import DotsSvg from "../asset/dots-anim-4.svg"; @@ -34,18 +33,22 @@ export const ConnectionButton = React.memo( let titleText = null; let shouldSpin = false; let connDisplayName: string = null; + let extraDisplayNameClassName = ""; if (isLocal) { - color = "var(--grey-text-color)"; + color = "var(--color-secondary)"; if (connection === "local:gitbash") { titleText = "Connected to Git Bash"; connDisplayName = "Git Bash"; } else { + const localName = getUserName() + "@" + getHostName(); titleText = "Connected to Local Machine"; + connDisplayName = localName; + extraDisplayNameClassName = "text-muted group-hover:text-secondary"; } connIconElem = ( ); } else { @@ -57,7 +60,7 @@ export const ConnectionButton = React.memo( titleText = "Connecting to " + connection; shouldSpin = false; iconSvg = ( -
+
); @@ -78,8 +81,8 @@ export const ConnectionButton = React.memo( } else { connIconElem = ( ); } @@ -90,23 +93,28 @@ export const ConnectionButton = React.memo( return ( <> -
- +
+ {connIconElem} {connDisplayName ? ( -
{connDisplayName}
+
{connDisplayName}
) : isLocal ? null : ( -
{connection}
+
{connection}
)}
{showNoWshButton && ( @@ -116,7 +124,6 @@ export const ConnectionButton = React.memo( icon: "link-slash", title: "wsh is not installed for this connection", }} - className="block-frame-header-iconbutton" /> )} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 30248d8e0e..a3c00e9874 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1262,8 +1262,6 @@ declare global { "web:openlinksinternally"?: boolean; "web:defaulturl"?: string; "web:defaultsearch"?: string; - "blockheader:*"?: boolean; - "blockheader:showblockids"?: boolean; "autoupdate:*"?: boolean; "autoupdate:enabled"?: boolean; "autoupdate:intervalms"?: number; diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index e79e9f0c17..0d1ff1bfba 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -63,9 +63,6 @@ const ( ConfigKey_WebDefaultUrl = "web:defaulturl" ConfigKey_WebDefaultSearch = "web:defaultsearch" - ConfigKey_BlockHeaderClear = "blockheader:*" - ConfigKey_BlockHeaderShowBlockIds = "blockheader:showblockids" - ConfigKey_AutoUpdateClear = "autoupdate:*" ConfigKey_AutoUpdateEnabled = "autoupdate:enabled" ConfigKey_AutoUpdateIntervalMs = "autoupdate:intervalms" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index de5993c67a..9a0e78d6bc 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -110,9 +110,6 @@ type SettingsType struct { WebDefaultUrl string `json:"web:defaulturl,omitempty"` WebDefaultSearch string `json:"web:defaultsearch,omitempty"` - BlockHeaderClear bool `json:"blockheader:*,omitempty"` - BlockHeaderShowBlockIds bool `json:"blockheader:showblockids,omitempty"` - AutoUpdateClear bool `json:"autoupdate:*,omitempty"` AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` AutoUpdateIntervalMs float64 `json:"autoupdate:intervalms,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index dd159b7e09..dd33adf26f 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -158,12 +158,6 @@ "web:defaultsearch": { "type": "string" }, - "blockheader:*": { - "type": "boolean" - }, - "blockheader:showblockids": { - "type": "boolean" - }, "autoupdate:*": { "type": "boolean" }, From 4a57fcb7272eec6a30a7cac324cb5bee97de387c Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 30 Jan 2026 22:37:41 -0800 Subject: [PATCH 11/34] use localhost name in terminal, allow override with settings --- docs/docs/config.mdx | 1 + frontend/app/block/connectionbutton.tsx | 4 ++-- frontend/app/modals/conntypeahead.tsx | 11 +++++++++-- frontend/app/store/global.ts | 11 +++++++++++ frontend/types/gotypes.d.ts | 1 + pkg/wconfig/metaconsts.go | 1 + pkg/wconfig/settingsconfig.go | 7 ++++--- schema/settings.json | 3 +++ 8 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index d88bae615f..c07544f11a 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -52,6 +52,7 @@ wsh editconfig | ai:timeoutms | int | timeout (in milliseconds) for AI calls | | ai:proxyurl | string | HTTP proxy URL for AI API requests (does not apply to Wave Cloud AI) | | conn:askbeforewshinstall | bool | set to false to disable popup asking if you want to install wsh extensions on new machines | +| conn:localhostdisplayname | string | override the display name for localhost in the UI (e.g., set to "My Laptop" or "Local", or set to empty string to hide the name) | | term:fontsize | float | the fontsize for the terminal block | | term:fontfamily | string | font family to use for terminal block | | term:disablewebgl | bool | set to false to disable WebGL acceleration in terminal | diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx index 71cbbdb6d0..4e909116c5 100644 --- a/frontend/app/block/connectionbutton.tsx +++ b/frontend/app/block/connectionbutton.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { computeConnColorNum } from "@/app/block/blockutil"; -import { getConnStatusAtom, getHostName, getUserName, recordTEvent } from "@/app/store/global"; +import { getConnStatusAtom, getLocalHostDisplayNameAtom, recordTEvent } from "@/app/store/global"; import { IconButton } from "@/element/iconbutton"; import * as util from "@/util/util"; import * as jotai from "jotai"; @@ -22,6 +22,7 @@ export const ConnectionButton = React.memo( const isLocal = util.isLocalConnName(connection); const connStatusAtom = getConnStatusAtom(connection); const connStatus = jotai.useAtomValue(connStatusAtom); + const localName = jotai.useAtomValue(getLocalHostDisplayNameAtom()); let showDisconnectedSlash = false; let connIconElem: React.ReactNode = null; const connColorNum = computeConnColorNum(connStatus); @@ -40,7 +41,6 @@ export const ConnectionButton = React.memo( titleText = "Connected to Git Bash"; connDisplayName = "Git Bash"; } else { - const localName = getUserName() + "@" + getHostName(); titleText = "Connected to Local Machine"; connDisplayName = localName; extraDisplayNameClassName = "text-muted group-hover:text-secondary"; diff --git a/frontend/app/modals/conntypeahead.tsx b/frontend/app/modals/conntypeahead.tsx index a84bf26dcb..1743f32dc9 100644 --- a/frontend/app/modals/conntypeahead.tsx +++ b/frontend/app/modals/conntypeahead.tsx @@ -4,7 +4,14 @@ import { computeConnColorNum } from "@/app/block/blockutil"; import { TypeAheadModal } from "@/app/modals/typeaheadmodal"; import { ConnectionsModel } from "@/app/store/connections-model"; -import { atoms, createBlock, getConnStatusAtom, getHostName, getUserName, globalStore, WOS } from "@/app/store/global"; +import { + atoms, + createBlock, + getConnStatusAtom, + getLocalHostDisplayNameAtom, + globalStore, + WOS, +} from "@/app/store/global"; import { globalRefocusWithTimeout } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -304,6 +311,7 @@ const ChangeConnectionBlockModal = React.memo( const fullConfig = jotai.useAtomValue(atoms.fullConfigAtom); let filterOutNowsh = util.useAtomValueSafe(viewModel.filterOutNowsh) ?? true; const hasGitBash = jotai.useAtomValue(ConnectionsModel.getInstance().hasGitBashAtom); + const localName = jotai.useAtomValue(getLocalHostDisplayNameAtom()); let maxActiveConnNum = 1; for (const conn of allConnStatus) { @@ -364,7 +372,6 @@ const ChangeConnectionBlockModal = React.memo( ); const reconnectSuggestionItem = getReconnectItem(connStatus, connSelected, blockId); - const localName = getUserName() + "@" + getHostName(); const localSuggestions = getLocalSuggestions( localName, wslList, diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 254969a331..560647d80a 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -663,6 +663,16 @@ function getHostName(): string { return cachedHostName; } +function getLocalHostDisplayNameAtom(): Atom { + return atom((get) => { + const configValue = get(getSettingsKeyAtom("conn:localhostdisplayname")); + if (configValue != null) { + return configValue; + } + return getUserName() + "@" + getHostName(); + }); +} + /** * Open a link in a new window, or in a new web widget. The user can set all links to open in a new web widget using the `web:openlinksinternally` setting. * @param uri The link to open. @@ -969,6 +979,7 @@ export { getConnStatusAtom, getFocusedBlockId, getHostName, + getLocalHostDisplayNameAtom, getObjectId, getOrefMetaKeyAtom, getOverrideConfigAtom, diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a3c00e9874..e61ef051eb 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1298,6 +1298,7 @@ declare global { "conn:*"?: boolean; "conn:askbeforewshinstall"?: boolean; "conn:wshenabled"?: boolean; + "conn:localhostdisplayname"?: string; "debug:*"?: boolean; "debug:pprofport"?: number; "debug:pprofmemprofilerate"?: number; diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 0d1ff1bfba..b681627b6d 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -106,6 +106,7 @@ const ( ConfigKey_ConnClear = "conn:*" ConfigKey_ConnAskBeforeWshInstall = "conn:askbeforewshinstall" ConfigKey_ConnWshEnabled = "conn:wshenabled" + ConfigKey_ConnLocalHostnameDisplay = "conn:localhostdisplayname" ConfigKey_DebugClear = "debug:*" ConfigKey_DebugPprofPort = "debug:pprofport" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 9a0e78d6bc..2aee1b1e60 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -150,9 +150,10 @@ type SettingsType struct { TelemetryClear bool `json:"telemetry:*,omitempty"` TelemetryEnabled bool `json:"telemetry:enabled,omitempty"` - ConnClear bool `json:"conn:*,omitempty"` - ConnAskBeforeWshInstall *bool `json:"conn:askbeforewshinstall,omitempty"` - ConnWshEnabled bool `json:"conn:wshenabled,omitempty"` + ConnClear bool `json:"conn:*,omitempty"` + ConnAskBeforeWshInstall *bool `json:"conn:askbeforewshinstall,omitempty"` + ConnWshEnabled bool `json:"conn:wshenabled,omitempty"` + ConnLocalHostnameDisplay *string `json:"conn:localhostdisplayname,omitempty"` DebugClear bool `json:"debug:*,omitempty"` DebugPprofPort *int `json:"debug:pprofport,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index dd33adf26f..e78511e33c 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -266,6 +266,9 @@ "conn:wshenabled": { "type": "boolean" }, + "conn:localhostdisplayname": { + "type": "string" + }, "debug:*": { "type": "boolean" }, From 2d2ea8e001b4a9b2601ba960bd3da7a15a5cfa9f Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 1 Feb 2026 12:53:08 -0800 Subject: [PATCH 12/34] display update --- frontend/app/block/connectionbutton.tsx | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx index 4e909116c5..0f6153b319 100644 --- a/frontend/app/block/connectionbutton.tsx +++ b/frontend/app/block/connectionbutton.tsx @@ -42,8 +42,13 @@ export const ConnectionButton = React.memo( connDisplayName = "Git Bash"; } else { titleText = "Connected to Local Machine"; - connDisplayName = localName; - extraDisplayNameClassName = "text-muted group-hover:text-secondary"; + if (localName) { + titleText += ` (${localName})`; + } + if (!isTerminalBlock) { + connDisplayName = localName; + extraDisplayNameClassName = "text-muted group-hover:text-secondary"; + } } connIconElem = ( {connIconElem} {connDisplayName ? ( -
{connDisplayName}
+
+ {connDisplayName} +
) : isLocal ? null : (
{connection}
)} From bdcc506ee25ba95e19d6e19e900bd437201dd9d3 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 1 Feb 2026 13:02:21 -0800 Subject: [PATCH 13/34] hide view name for web/preview widgets too --- frontend/app/block/blockframe-header.tsx | 3 ++- frontend/app/block/connectionbutton.tsx | 2 +- frontend/app/view/preview/preview-model.tsx | 2 ++ frontend/app/view/webview/webview.tsx | 2 ++ frontend/types/custom.d.ts | 2 ++ 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index e9e90b5bee..5b21c449dd 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -172,6 +172,7 @@ const BlockFrame_Header = ({ const preIconButton = util.useAtomValueSafe(viewModel?.preIconButton); const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader); const termDurableStatus = util.useAtomValueSafe(viewModel?.termDurableStatus); + const hideViewName = util.useAtomValueSafe(viewModel?.hideViewName); const magnified = jotai.useAtomValue(nodeModel.isMagnified); const prevMagifiedState = React.useRef(magnified); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); @@ -204,7 +205,7 @@ const BlockFrame_Header = ({ {preIconButton && }
{viewIconElem} - {viewName &&
{viewName}
} + {viewName && !hideViewName &&
{viewName}
}
)} diff --git a/frontend/app/block/connectionbutton.tsx b/frontend/app/block/connectionbutton.tsx index 0f6153b319..1f74f00967 100644 --- a/frontend/app/block/connectionbutton.tsx +++ b/frontend/app/block/connectionbutton.tsx @@ -45,7 +45,7 @@ export const ConnectionButton = React.memo( if (localName) { titleText += ` (${localName})`; } - if (!isTerminalBlock) { + if (isTerminalBlock) { connDisplayName = localName; extraDisplayNameClassName = "text-muted group-hover:text-secondary"; } diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 6267de99bb..630237ed37 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -127,6 +127,7 @@ export class PreviewModel implements ViewModel { viewText: Atom; preIconButton: Atom; endIconButtons: Atom; + hideViewName: Atom; previewTextRef: React.RefObject; editMode: Atom; canPreview: PrimitiveAtom; @@ -219,6 +220,7 @@ export class PreviewModel implements ViewModel { return blockData?.meta?.edit ?? false; }); this.viewName = atom("Preview"); + this.hideViewName = atom(true); this.viewText = atom((get) => { let headerPath = get(this.metaFilePath); const connStatus = get(this.connStatus); diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index bfa3476bd3..5db4375f7b 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -51,6 +51,7 @@ export class WebViewModel implements ViewModel { viewIcon: Atom; viewName: Atom; viewText: Atom; + hideViewName: Atom; url: PrimitiveAtom; homepageUrl: Atom; urlInputFocused: PrimitiveAtom; @@ -91,6 +92,7 @@ export class WebViewModel implements ViewModel { this.refreshIcon = atom("rotate-right"); this.viewIcon = atom("globe"); this.viewName = atom("Web"); + this.hideViewName = atom(true); this.urlInputRef = createRef(); this.webviewRef = createRef(); this.domReady = atom(false); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 1192e40517..d1eef659db 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -300,6 +300,8 @@ declare global { useTermHeader?: jotai.Atom; + hideViewName?: jotai.Atom; + // Icon representing the view, can be a string or an IconButton declaration. viewIcon?: jotai.Atom; From e72fb345b255761c701ee1dbaa9555a113ca6ff8 Mon Sep 17 00:00:00 2001 From: sawka Date: Sun, 1 Feb 2026 13:25:16 -0800 Subject: [PATCH 14/34] fix issue with re-creating localconnname atom --- frontend/app/store/global.ts | 16 +++++++++------- frontend/app/view/preview/preview-directory.tsx | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 560647d80a..69d554e44c 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -663,14 +663,16 @@ function getHostName(): string { return cachedHostName; } +const LocalHostDisplayNameAtom: Atom = atom((get) => { + const configValue = get(getSettingsKeyAtom("conn:localhostdisplayname")); + if (configValue != null) { + return configValue; + } + return getUserName() + "@" + getHostName(); +}); + function getLocalHostDisplayNameAtom(): Atom { - return atom((get) => { - const configValue = get(getSettingsKeyAtom("conn:localhostdisplayname")); - if (configValue != null) { - return configValue; - } - return getUserName() + "@" + getHostName(); - }); + return LocalHostDisplayNameAtom; } /** diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index c4bceed5c0..affd6e8069 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -238,7 +238,7 @@ function DirectoryTable({ useEffect(() => { const allRows = table.getRowModel()?.flatRows || []; setSelectedPath((allRows[focusIndex]?.getValue("path") as string) ?? null); - }, [table, focusIndex, data]); + }, [focusIndex, data, setSelectedPath]); const columnSizeVars = useMemo(() => { const headers = table.getFlatHeaders(); From 56b29761e02758d97338cd66748b814c59a93c71 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 2 Feb 2026 18:36:15 -0800 Subject: [PATCH 15/34] status updating working for jobs across BE => FE --- cmd/wsh/cmd/wshcmd-jobdebug.go | 4 +- frontend/app/block/blockframe-header.tsx | 45 ++++- frontend/app/store/wshclientapi.ts | 15 +- frontend/app/view/codeeditor/codeeditor.tsx | 6 +- frontend/app/view/term/term-model.ts | 48 ++++- frontend/app/view/term/term-wsh.tsx | 5 - .../builder/store/builder-apppanel-model.ts | 3 +- frontend/builder/tabs/builder-codetab.tsx | 25 ++- frontend/types/custom.d.ts | 2 +- frontend/types/gotypes.d.ts | 21 +- pkg/blockcontroller/durableshellcontroller.go | 2 +- pkg/jobcontroller/jobcontroller.go | 186 ++++++++++++++---- pkg/tsgen/tsgen.go | 1 + pkg/wps/wpstypes.go | 5 +- pkg/wshrpc/wshclient/wshclient.go | 18 +- pkg/wshrpc/wshrpctypes.go | 16 +- pkg/wshrpc/wshserver/wshserver.go | 4 + 17 files changed, 317 insertions(+), 89 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-jobdebug.go b/cmd/wsh/cmd/wshcmd-jobdebug.go index 25b738b41a..34e1b85540 100644 --- a/cmd/wsh/cmd/wshcmd-jobdebug.go +++ b/cmd/wsh/cmd/wshcmd-jobdebug.go @@ -178,7 +178,7 @@ func jobDebugListRun(cmd *cobra.Command, args []string) error { return nil } - fmt.Printf("%-36s %-20s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", "OID", "Connection", "Connected", "Manager", "Reason", "Cmd", "ExitCode", "Stream", "Attached") + fmt.Printf("%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", "OID", "Connection", "Connected", "Manager", "Reason", "Cmd", "ExitCode", "Stream", "Attached") for _, job := range rtnData { connectedStatus := "no" if connectedMap[job.OID] { @@ -226,7 +226,7 @@ func jobDebugListRun(cmd *cobra.Command, args []string) error { } } - fmt.Printf("%-36s %-20s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", + fmt.Printf("%-36s %-25s %-9s %-10s %-6s %-30s %-8s %-10s %-8s\n", job.OID, job.Connection, connectedStatus, job.JobManagerStatus, doneReason, job.Cmd, exitCode, streamStatus, attachedBlock) } return nil diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 5b21c449dd..0f0552c815 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -10,7 +10,7 @@ import { } from "@/app/block/blockutil"; import { ConnectionButton } from "@/app/block/connectionbutton"; import { ContextMenuModel } from "@/app/store/contextmenu"; -import { recordTEvent, WOS } from "@/app/store/global"; +import { getConnStatusAtom, recordTEvent, WOS } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { uxCloseBlock } from "@/app/store/keymodel"; import { RpcApi } from "@/app/store/wshclientapi"; @@ -23,6 +23,43 @@ import * as jotai from "jotai"; import * as React from "react"; import { BlockFrameProps } from "./blocktypes"; +function getDurableIconProps(jobStatus: BlockJobStatusData, connStatus: ConnStatus) { + let color = "text-muted"; + let titleText = "Durable Session"; + const status = jobStatus?.status; + if (status === "connected") { + color = "text-sky-500"; + titleText = "Durable Session (Attached)"; + } else if (status === "disconnected") { + color = "text-sky-300"; + titleText = "Durable Session (Detached)"; + } else if (status === "init") { + color = "text-sky-300"; + titleText = "Durable Session (Starting)"; + } else if (status === "done") { + color = "text-muted"; + const doneReason = jobStatus?.donereason; + if (doneReason === "terminated") { + titleText = "Durable Session (Ended, Exited)"; + } else if (doneReason === "gone") { + titleText = "Durable Session (Ended, Environment Lost)"; + } else if (doneReason === "startuperror") { + titleText = "Durable Session (Ended, Failed to Start)"; + } else { + titleText = "Durable Session (Ended)"; + } + } else if (status == null) { + if (!connStatus?.connected) { + color = "text-muted"; + titleText = "Durable Session (Awaiting Connection)"; + } else { + color = "text-muted"; + titleText = "No Session"; + } + } + return { color, titleText }; +} + function handleHeaderContextMenu( e: React.MouseEvent, blockId: string, @@ -180,6 +217,8 @@ const BlockFrame_Header = ({ const isTerminalBlock = blockData?.meta?.view === "term"; viewName = blockData?.meta?.["frame:title"] ?? viewName; viewIconUnion = blockData?.meta?.["frame:icon"] ?? viewIconUnion; + const connName = blockData?.meta?.connection; + const connStatus = jotai.useAtomValue(getConnStatusAtom(connName)); React.useEffect(() => { // this is an effect and "reactive" since mangification can happen via keyboard or button click @@ -193,6 +232,8 @@ const BlockFrame_Header = ({ const viewIconElem = getViewIconElem(viewIconUnion, blockData); + const { color: durableIconColor, titleText: durableTitle } = getDurableIconProps(termDurableStatus, connStatus); + return (
- +
)} diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8fc1aa4f08..72ffd8618d 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -52,6 +52,11 @@ class RpcApiType { return client.wshRpcCall("blockinfo", data, opts); } + // command "blockjobstatus" [call] + BlockJobStatusCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { + return client.wshRpcCall("blockjobstatus", data, opts); + } + // command "blockslist" [call] BlocksListCommand(client: WshClient, data: BlocksListRequest, opts?: RpcOpts): Promise { return client.wshRpcCall("blockslist", data, opts); @@ -432,6 +437,11 @@ class RpcApiType { return client.wshRpcCall("jobcontrollerexitjob", data, opts); } + // command "jobcontrollergetalljobmanagerstatus" [call] + JobControllerGetAllJobManagerStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("jobcontrollergetalljobmanagerstatus", null, opts); + } + // command "jobcontrollerlist" [call] JobControllerListCommand(client: WshClient, opts?: RpcOpts): Promise { return client.wshRpcCall("jobcontrollerlist", null, opts); @@ -722,11 +732,6 @@ class RpcApiType { return client.wshRpcCall("termgetscrollbacklines", data, opts); } - // command "termupdateattachedjob" [call] - TermUpdateAttachedJobCommand(client: WshClient, data: CommandTermUpdateAttachedJobData, opts?: RpcOpts): Promise { - return client.wshRpcCall("termupdateattachedjob", data, opts); - } - // command "test" [call] TestCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { return client.wshRpcCall("test", data, opts); diff --git a/frontend/app/view/codeeditor/codeeditor.tsx b/frontend/app/view/codeeditor/codeeditor.tsx index 2f93264cbf..48461887b5 100644 --- a/frontend/app/view/codeeditor/codeeditor.tsx +++ b/frontend/app/view/codeeditor/codeeditor.tsx @@ -75,9 +75,11 @@ export function CodeEditor({ blockId, text, language, fileName, readonly, onChan monaco: typeof MonacoModule ): () => void { if (onMount) { - unmountRef.current = onMount(editor, monaco); + const cleanup = onMount(editor, monaco); + unmountRef.current = cleanup; + return cleanup; } - return null; + return undefined; } const editorOpts = useMemo(() => { diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 1eb43b5c85..887f08123c 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -72,10 +72,13 @@ export class TermViewModel implements ViewModel { shellProcFullStatus: jotai.PrimitiveAtom; shellProcStatus: jotai.Atom; shellProcStatusUnsubFn: () => void; + blockJobStatusAtom: jotai.PrimitiveAtom; + blockJobStatusVersionTs: number; + blockJobStatusUnsubFn: () => void; termBPMUnsubFn: () => void; isCmdController: jotai.Atom; isRestarting: jotai.PrimitiveAtom; - termDurableStatus: jotai.Atom<"connected" | "connecting" | "running" | "gone">; + termDurableStatus: jotai.Atom; searchAtoms?: SearchAtoms; constructor(blockId: string, nodeModel: BlockNodeModel, tabModel: TabModel) { @@ -342,10 +345,27 @@ export class TermViewModel implements ViewModel { }); this.termDurableStatus = jotai.atom((get) => { const isDurable = get(getBlockTermDurableAtom(this.blockId)); - if (isDurable) { - return "running"; + if (!isDurable) { + return null; } - return null; + const blockJobStatus = get(this.blockJobStatusAtom); + if (blockJobStatus?.jobid == null || blockJobStatus?.status == null) { + return null; + } + return blockJobStatus; + }); + this.blockJobStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; + this.blockJobStatusVersionTs = 0; + const initialBlockJobStatus = RpcApi.BlockJobStatusCommand(TabRpcClient, blockId); + initialBlockJobStatus.then((status) => { + this.handleBlockJobStatusUpdate(status); + }); + this.blockJobStatusUnsubFn = waveEventSubscribe({ + eventType: "block:jobstatus", + scope: `block:${blockId}`, + handler: (event) => { + this.handleBlockJobStatusUpdate(event.data); + }, }); this.termBPMUnsubFn = globalStore.sub(this.termBPMAtom, () => { if (this.termRef.current?.terminal) { @@ -448,6 +468,17 @@ export class TermViewModel implements ViewModel { }, 300); } + handleBlockJobStatusUpdate(status: BlockJobStatusData) { + if (status?.versionts == null) { + return; + } + if (status.versionts <= this.blockJobStatusVersionTs) { + return; + } + this.blockJobStatusVersionTs = status.versionts; + globalStore.set(this.blockJobStatusAtom, status); + } + updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { if (fullStatus == null) { return; @@ -484,12 +515,9 @@ export class TermViewModel implements ViewModel { dispose() { DefaultRouter.unregisterRoute(makeFeBlockRouteId(this.blockId)); - if (this.shellProcStatusUnsubFn) { - this.shellProcStatusUnsubFn(); - } - if (this.termBPMUnsubFn) { - this.termBPMUnsubFn(); - } + this.shellProcStatusUnsubFn?.(); + this.blockJobStatusUnsubFn?.(); + this.termBPMUnsubFn?.(); } giveFocus(): boolean { diff --git a/frontend/app/view/term/term-wsh.tsx b/frontend/app/view/term/term-wsh.tsx index 16e31ae334..782a174913 100644 --- a/frontend/app/view/term/term-wsh.tsx +++ b/frontend/app/view/term/term-wsh.tsx @@ -104,11 +104,6 @@ export class TermWshClient extends WshClient { } } - async handle_termupdateattachedjob(rh: RpcResponseHelper, data: CommandTermUpdateAttachedJobData): Promise { - console.log("term-update-attached-job", this.blockId, data); - // TODO: implement frontend logic to handle job attachment updates - } - async handle_termgetscrollbacklines( rh: RpcResponseHelper, data: CommandTermGetScrollbackLinesData diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index f05e932aa6..15a18e3ec3 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -8,6 +8,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms, getApi, WOS } from "@/store/global"; import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; +import type * as MonacoTypes from "monaco-editor"; import { debounce } from "throttle-debounce"; export type TabType = "preview" | "files" | "code" | "secrets" | "configdata"; @@ -33,7 +34,7 @@ export class BuilderAppPanelModel { hasSecretsAtom: PrimitiveAtom = atom(false); saveNeededAtom!: Atom; focusElemRef: { current: HTMLInputElement | null } = { current: null }; - monacoEditorRef: { current: any | null } = { current: null }; + monacoEditorRef: { current: MonacoTypes.editor.IStandaloneCodeEditor | null } = { current: null }; statusUnsubFn: (() => void) | null = null; appGoUpdateUnsubFn: (() => void) | null = null; debouncedRestart: (() => void) & { cancel: () => void }; diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index e54938eefd..454f6013aa 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -7,7 +7,9 @@ import { atoms } from "@/store/global"; import * as keyutil from "@/util/keyutil"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; -import { memo } from "react"; +import type * as MonacoTypes from "monaco-editor"; +import * as Monaco from "monaco-editor"; +import { memo, useEffect } from "react"; const BuilderCodeTab = memo(() => { const model = BuilderAppPanelModel.getInstance(); @@ -16,12 +18,21 @@ const BuilderCodeTab = memo(() => { const isLoading = useAtomValue(model.isLoadingAtom); const error = useAtomValue(model.errorAtom); const saveNeeded = useAtomValue(model.saveNeededAtom); + const activeTab = useAtomValue(model.activeTab); + + useEffect(() => { + if (activeTab === "code" && model.monacoEditorRef.current) { + setTimeout(() => { + model.monacoEditorRef.current?.layout(); + }, 0); + } + }, [activeTab, model.monacoEditorRef]); const handleCodeChange = (newText: string) => { model.setCodeContent(newText); }; - const handleEditorMount = (editor: any) => { + const handleEditorMount = (editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof Monaco) => { model.setMonacoEditorRef(editor); return () => { model.setMonacoEditorRef(null); @@ -42,6 +53,14 @@ const BuilderCodeTab = memo(() => { return false; }); + if (!builderAppId) { + return ( +
+
No builder app selected
+
+ ); + } + if (isLoading) { return (
@@ -72,7 +91,7 @@ const BuilderCodeTab = memo(() => { Save ; - termDurableStatus?: jotai.Atom<"connected" | "connecting" | "running" | "gone">; + termDurableStatus?: jotai.Atom; // Icon button displayed before the title in the header. preIconButton?: jotai.Atom; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index e61ef051eb..c8e59c7c10 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -140,6 +140,15 @@ declare global { files: WaveFileInfo[]; }; + // wshrpc.BlockJobStatusData + type BlockJobStatusData = { + blockid: string; + jobid: string; + status: null | "init" | "connected" | "disconnected" | "done"; + versionts: number; + donereason?: string; + }; + // wshrpc.BlocksListEntry type BlocksListEntry = { windowid: string; @@ -610,12 +619,6 @@ declare global { lastupdated: number; }; - // wshrpc.CommandTermUpdateAttachedJobData - type CommandTermUpdateAttachedJobData = { - blockid: string; - jobid?: string; - }; - // wshrpc.CommandVarData type CommandVarData = { key: string; @@ -941,6 +944,12 @@ declare global { streamerror?: string; }; + // wshrpc.JobManagerStatusUpdate + type JobManagerStatusUpdate = { + jobid: string; + jobmanagerstatus: string; + }; + // waveobj.LayoutActionData type LayoutActionData = { actiontype: string; diff --git a/pkg/blockcontroller/durableshellcontroller.go b/pkg/blockcontroller/durableshellcontroller.go index 81f87fe9db..e48974d6b4 100644 --- a/pkg/blockcontroller/durableshellcontroller.go +++ b/pkg/blockcontroller/durableshellcontroller.go @@ -145,7 +145,7 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj. if err != nil { return fmt.Errorf("error getting job manager status: %w", err) } - if status != jobcontroller.JobStatus_Running { + if status != jobcontroller.JobManagerStatus_Running { if force { log.Printf("block %q has jobId %s but manager is not running (status: %s), detaching (force=true)\n", dsc.BlockId, blockData.JobId, status) jobcontroller.DetachJobFromBlock(ctx, blockData.JobId, false) diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index 7517f3ada0..fb392fb240 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -20,6 +20,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/streamclient" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" @@ -31,9 +32,9 @@ import ( ) const ( - JobStatus_Init = "init" - JobStatus_Running = "running" - JobStatus_Done = "done" + JobManagerStatus_Init = "init" + JobManagerStatus_Running = "running" + JobManagerStatus_Done = "done" ) const ( @@ -71,8 +72,9 @@ type jobState struct { } var ( - jobConnStates = make(map[string]string) - jobControllerLock sync.Mutex + jobConnStates = make(map[string]string) + jobControllerLock sync.Mutex + blockJobStatusVersion utilds.VersionTs connStates = &connStateManager{ m: make(map[string]*connState), @@ -81,7 +83,7 @@ var ( ) func isJobManagerRunning(job *waveobj.Job) bool { - return job.JobManagerStatus == JobStatus_Running + return job.JobManagerStatus == JobManagerStatus_Running } func GetJobManagerStatus(ctx context.Context, jobId string) (string, error) { @@ -90,11 +92,93 @@ func GetJobManagerStatus(ctx context.Context, jobId string) (string, error) { return "", fmt.Errorf("failed to get job: %w", err) } if job == nil { - return JobStatus_Done, nil + return JobManagerStatus_Done, nil } return job.JobManagerStatus, nil } +func GetAllJobManagerStatus(ctx context.Context) ([]*wshrpc.JobManagerStatusUpdate, error) { + allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) + if err != nil { + return nil, fmt.Errorf("failed to get jobs: %w", err) + } + + var statuses []*wshrpc.JobManagerStatusUpdate + for _, job := range allJobs { + statuses = append(statuses, &wshrpc.JobManagerStatusUpdate{ + JobId: job.OID, + JobManagerStatus: job.JobManagerStatus, + }) + } + + return statuses, nil +} + +func GetBlockJobStatus(ctx context.Context, blockId string) (*wshrpc.BlockJobStatusData, error) { + block, err := wstore.DBGet[*waveobj.Block](ctx, blockId) + if err != nil { + return nil, fmt.Errorf("failed to get block: %w", err) + } + if block == nil { + return nil, fmt.Errorf("block not found: %s", blockId) + } + + data := &wshrpc.BlockJobStatusData{ + BlockId: blockId, + VersionTs: blockJobStatusVersion.GetVersionTs(), + } + + if block.JobId == "" { + return data, nil + } + + job, err := wstore.DBGet[*waveobj.Job](ctx, block.JobId) + if err != nil { + return nil, fmt.Errorf("failed to get job: %w", err) + } + if job == nil { + return data, nil + } + + data.JobId = job.OID + data.DoneReason = job.JobManagerDoneReason + + if job.JobManagerStatus == JobManagerStatus_Init { + data.Status = "init" + } else if job.JobManagerStatus == JobManagerStatus_Done { + data.Status = "done" + } else if job.JobManagerStatus == JobManagerStatus_Running { + connStatus := GetJobConnStatus(job.OID) + if connStatus == JobConnStatus_Connected { + data.Status = "connected" + } else { + data.Status = "disconnected" + } + } + + return data, nil +} + +func SendBlockJobStatusEvent(ctx context.Context, blockId string) { + data, err := GetBlockJobStatus(ctx, blockId) + if err != nil { + log.Printf("[block:%s] error getting block job status: %v", blockId, err) + return + } + wps.Broker.Publish(wps.WaveEvent{ + Event: wps.Event_BlockJobStatus, + Scopes: []string{fmt.Sprintf("block:%s", blockId)}, + Data: data, + }) +} + +func sendBlockJobStatusEventByJob(ctx context.Context, job *waveobj.Job) { + if job == nil || job.AttachedBlockId == "" { + return + } + SendBlockJobStatusEvent(ctx, job.AttachedBlockId) +} + func connReconcileWorker() { defer func() { panichandler.PanicHandler("jobcontroller:connReconcileWorker", recover()) @@ -188,11 +272,19 @@ func handleRouteDownEvent(event *wps.WaveEvent) { } func handleRouteEvent(event *wps.WaveEvent, newStatus string) { + ctx := context.Background() for _, scope := range event.Scopes { if strings.HasPrefix(scope, "job:") { jobId := strings.TrimPrefix(scope, "job:") SetJobConnStatus(jobId, newStatus) log.Printf("[job:%s] connection status changed to %s", jobId, newStatus) + + job, err := wstore.DBGet[*waveobj.Job](ctx, jobId) + if err != nil { + log.Printf("[job:%s] error getting job for status event: %v", jobId, err) + continue + } + sendBlockJobStatusEventByJob(ctx, job) } } } @@ -232,36 +324,36 @@ func handleConnChangeEvent(event *wps.WaveEvent) { } func onConnectionUp(connName string) { - log.Printf("[conn:%s] connection became connected, terminating jobs with TerminateOnReconnect", connName) + log.Printf("[conn:%s] connection became connected, reconnecting jobs", connName) ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() allJobs, err := wstore.DBGetAllObjsByType[*waveobj.Job](ctx, waveobj.OType_Job) if err != nil { - log.Printf("[conn:%s] failed to get jobs for termination: %v", connName, err) + log.Printf("[conn:%s] failed to get jobs for reconnection: %v", connName, err) return } - var jobsToTerminate []*waveobj.Job + var jobsToReconnect []*waveobj.Job for _, job := range allJobs { - if job.Connection == connName && job.TerminateOnReconnect { - jobsToTerminate = append(jobsToTerminate, job) + if job.Connection == connName && isJobManagerRunning(job) { + jobsToReconnect = append(jobsToReconnect, job) } } - log.Printf("[conn:%s] found %d jobs to terminate", connName, len(jobsToTerminate)) + log.Printf("[conn:%s] found %d jobs to reconnect", connName, len(jobsToReconnect)) successCount := 0 - for _, job := range jobsToTerminate { - err = remoteTerminateJobManager(ctx, job) + for _, job := range jobsToReconnect { + err = ReconnectJob(ctx, job.OID, nil) if err != nil { - log.Printf("[job:%s] error terminating: %v", job.OID, err) + log.Printf("[job:%s] error reconnecting: %v", job.OID, err) } else { successCount++ } } - log.Printf("[conn:%s] finished terminating jobs: %d/%d successful", connName, successCount, len(jobsToTerminate)) + log.Printf("[conn:%s] finished reconnecting jobs: %d/%d successful", connName, successCount, len(jobsToReconnect)) } func onConnectionDown(connName string) { @@ -372,7 +464,7 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { CmdEnv: params.Env, CmdTermSize: *params.TermSize, JobAuthToken: jobAuthToken, - JobManagerStatus: JobStatus_Init, + JobManagerStatus: JobManagerStatus_Init, Meta: make(waveobj.MetaMapType), } @@ -380,6 +472,7 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { if err != nil { return "", fmt.Errorf("failed to create job in database: %w", err) } + sendBlockJobStatusEventByJob(ctx, job) bareRpc := wshclient.GetBareRpcClient() broker := bareRpc.StreamBroker @@ -424,26 +517,32 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { if err != nil { log.Printf("[job:%s] RemoteStartJobCommand failed: %v", jobId, err) errMsg := fmt.Sprintf("failed to start job: %v", err) + var updatedJob *waveobj.Job wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { - job.JobManagerStatus = JobStatus_Done + job.JobManagerStatus = JobManagerStatus_Done job.JobManagerDoneReason = JobDoneReason_StartupError job.JobManagerStartupError = errMsg + updatedJob = job }) + sendBlockJobStatusEventByJob(ctx, updatedJob) return "", fmt.Errorf("failed to start remote job: %w", err) } log.Printf("[job:%s] RemoteStartJobCommand succeeded, cmdpid=%d cmdstartts=%d jobmanagerpid=%d jobmanagerstartts=%d", jobId, rtnData.CmdPid, rtnData.CmdStartTs, rtnData.JobManagerPid, rtnData.JobManagerStartTs) + var updatedJob *waveobj.Job err = wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.CmdPid = rtnData.CmdPid job.CmdStartTs = rtnData.CmdStartTs job.JobManagerPid = rtnData.JobManagerPid job.JobManagerStartTs = rtnData.JobManagerStartTs - job.JobManagerStatus = JobStatus_Running + job.JobManagerStatus = JobManagerStatus_Running + updatedJob = job }) if err != nil { log.Printf("[job:%s] warning: failed to update job status to running: %v", jobId, err) } else { log.Printf("[job:%s] job status updated to running", jobId) + sendBlockJobStatusEventByJob(ctx, updatedJob) } go func() { @@ -564,7 +663,7 @@ func tryTerminateJobManager(ctx context.Context, jobId string) { return } - if job.JobManagerStatus != JobStatus_Running { + if job.JobManagerStatus != JobManagerStatus_Running { return } @@ -645,17 +744,21 @@ func remoteTerminateJobManager(ctx context.Context, job *waveobj.Job) error { return fmt.Errorf("failed to terminate job manager: %w", err) } + var updatedJob *waveobj.Job updateErr := wstore.DBUpdateFn(ctx, job.OID, func(job *waveobj.Job) { - job.JobManagerStatus = JobStatus_Done + job.JobManagerStatus = JobManagerStatus_Done job.JobManagerDoneReason = JobDoneReason_Terminated job.TerminateOnReconnect = false if !job.StreamDone { job.StreamDone = true job.StreamError = "job manager terminated" } + updatedJob = job }) if updateErr != nil { log.Printf("[job:%s] error updating job status after termination: %v", job.OID, updateErr) + } else { + sendBlockJobStatusEventByJob(ctx, updatedJob) } log.Printf("[job:%s] job manager terminated successfully", job.OID) @@ -679,6 +782,12 @@ func ReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts return remoteTerminateJobManager(ctx, job) } + if rtOpts == nil { + rtOpts = &waveobj.RuntimeOpts{ + TermSize: job.CmdTermSize, + } + } + bareRpc := wshclient.GetBareRpcClient() jobAccessClaims := &wavejwt.WaveJwtClaims{ @@ -713,12 +822,16 @@ func ReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts if !rtnData.Success { log.Printf("[job:%s] RemoteReconnectToJobManagerCommand returned error: %s", jobId, rtnData.Error) if rtnData.JobManagerGone { + var updatedJob *waveobj.Job updateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { - job.JobManagerStatus = JobStatus_Done + job.JobManagerStatus = JobManagerStatus_Done job.JobManagerDoneReason = JobDoneReason_Gone + updatedJob = job }) if updateErr != nil { log.Printf("[job:%s] error updating job manager running status: %v", jobId, updateErr) + } else { + sendBlockJobStatusEventByJob(ctx, updatedJob) } return fmt.Errorf("job manager has exited: %s", rtnData.Error) } @@ -844,14 +957,18 @@ func restartStreaming(ctx context.Context, jobId string, knownConnected bool, rt exitCodeStr = fmt.Sprintf("%d", *rtnData.ExitCode) } log.Printf("[job:%s] job has already exited: code=%s signal=%q err=%q", jobId, exitCodeStr, rtnData.ExitSignal, rtnData.ExitErr) + var updatedJob *waveobj.Job updateErr := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { - job.JobManagerStatus = JobStatus_Done + job.JobManagerStatus = JobManagerStatus_Done job.CmdExitCode = rtnData.ExitCode job.CmdExitSignal = rtnData.ExitSignal job.CmdExitError = rtnData.ExitErr + updatedJob = job }) if updateErr != nil { log.Printf("[job:%s] error updating job exit status: %v", jobId, updateErr) + } else { + sendBlockJobStatusEventByJob(ctx, updatedJob) } } @@ -999,16 +1116,7 @@ func AttachJobToBlock(ctx context.Context, jobId string, blockId string) error { return err } - rpcOpts := &wshrpc.RpcOpts{ - Route: wshutil.MakeFeBlockRouteId(blockId), - NoResponse: true, - } - bareRpc := wshclient.GetBareRpcClient() - wshclient.TermUpdateAttachedJobCommand(bareRpc, wshrpc.CommandTermUpdateAttachedJobData{ - BlockId: blockId, - JobId: jobId, - }, rpcOpts) - + SendBlockJobStatusEvent(ctx, blockId) return nil } @@ -1052,15 +1160,7 @@ func DetachJobFromBlock(ctx context.Context, jobId string, updateBlock bool) err } if blockId != "" { - rpcOpts := &wshrpc.RpcOpts{ - Route: wshutil.MakeFeBlockRouteId(blockId), - NoResponse: true, - } - bareRpc := wshclient.GetBareRpcClient() - wshclient.TermUpdateAttachedJobCommand(bareRpc, wshrpc.CommandTermUpdateAttachedJobData{ - BlockId: blockId, - JobId: "", - }, rpcOpts) + SendBlockJobStatusEvent(ctx, blockId) } return nil diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index 963a304ffc..54b29dc6dd 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -55,6 +55,7 @@ var ExtraTypes = []any{ uctypes.RateLimitInfo{}, wconfig.AIModeConfigUpdate{}, wshrpc.TabIndicatorEventData{}, + wshrpc.BlockJobStatusData{}, } // add extra type unions to generate here diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index d36be631f1..0bf110a7c5 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -3,7 +3,9 @@ package wps -import "github.com/wavetermdev/waveterm/pkg/util/utilfn" +import ( + "github.com/wavetermdev/waveterm/pkg/util/utilfn" +) const ( Event_BlockClose = "blockclose" @@ -24,6 +26,7 @@ const ( Event_TsunamiUpdateMeta = "tsunami:updatemeta" Event_AIModeConfig = "waveai:modeconfig" Event_TabIndicator = "tab:indicator" + Event_BlockJobStatus = "block:jobstatus" // type: BlockJobStatusData ) type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index fe1f4b5f97..533fb01c24 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -70,6 +70,12 @@ func BlockInfoCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*ws return resp, err } +// command "blockjobstatus", wshserver.BlockJobStatusCommand +func BlockJobStatusCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) (*wshrpc.BlockJobStatusData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.BlockJobStatusData](w, "blockjobstatus", data, opts) + return resp, err +} + // command "blockslist", wshserver.BlocksListCommand func BlocksListCommand(w *wshutil.WshRpc, data wshrpc.BlocksListRequest, opts *wshrpc.RpcOpts) ([]wshrpc.BlocksListEntry, error) { resp, err := sendRpcRequestCallHelper[[]wshrpc.BlocksListEntry](w, "blockslist", data, opts) @@ -524,6 +530,12 @@ func JobControllerExitJobCommand(w *wshutil.WshRpc, data string, opts *wshrpc.Rp return err } +// command "jobcontrollergetalljobmanagerstatus", wshserver.JobControllerGetAllJobManagerStatusCommand +func JobControllerGetAllJobManagerStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]*wshrpc.JobManagerStatusUpdate, error) { + resp, err := sendRpcRequestCallHelper[[]*wshrpc.JobManagerStatusUpdate](w, "jobcontrollergetalljobmanagerstatus", nil, opts) + return resp, err +} + // command "jobcontrollerlist", wshserver.JobControllerListCommand func JobControllerListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]*waveobj.Job, error) { resp, err := sendRpcRequestCallHelper[[]*waveobj.Job](w, "jobcontrollerlist", nil, opts) @@ -866,12 +878,6 @@ func TermGetScrollbackLinesCommand(w *wshutil.WshRpc, data wshrpc.CommandTermGet return resp, err } -// command "termupdateattachedjob", wshserver.TermUpdateAttachedJobCommand -func TermUpdateAttachedJobCommand(w *wshutil.WshRpc, data wshrpc.CommandTermUpdateAttachedJobData, opts *wshrpc.RpcOpts) error { - _, err := sendRpcRequestCallHelper[any](w, "termupdateattachedjob", data, opts) - return err -} - // command "test", wshserver.TestCommand func TestCommand(w *wshutil.WshRpc, data string, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "test", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 001741a7b9..1da0a2280e 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -160,7 +160,6 @@ type WshRpcInterface interface { // terminal TermGetScrollbackLinesCommand(ctx context.Context, data CommandTermGetScrollbackLinesData) (*CommandTermGetScrollbackLinesRtnData, error) - TermUpdateAttachedJobCommand(ctx context.Context, data CommandTermUpdateAttachedJobData) error // file WshRpcFileInterface @@ -196,6 +195,8 @@ type WshRpcInterface interface { JobControllerConnectedJobsCommand(ctx context.Context) ([]string, error) JobControllerAttachJobCommand(ctx context.Context, data CommandJobControllerAttachJobData) error JobControllerDetachJobCommand(ctx context.Context, jobId string) error + JobControllerGetAllJobManagerStatusCommand(ctx context.Context) ([]*JobManagerStatusUpdate, error) + BlockJobStatusCommand(ctx context.Context, blockId string) (*BlockJobStatusData, error) } // for frontend @@ -829,6 +830,11 @@ type CommandJobControllerAttachJobData struct { BlockId string `json:"blockid"` } +type JobManagerStatusUpdate struct { + JobId string `json:"jobid"` + JobManagerStatus string `json:"jobmanagerstatus"` +} + type CommandWaveFileReadStreamData struct { ZoneId string `json:"zoneid"` Name string `json:"name"` @@ -858,3 +864,11 @@ type TabIndicatorEventData struct { TabId string `json:"tabid"` Indicator *TabIndicator `json:"indicator"` } + +type BlockJobStatusData struct { + BlockId string `json:"blockid"` + JobId string `json:"jobid"` + Status string `json:"status" tstype:"null | \"init\" | \"connected\" | \"disconnected\" | \"done\""` + VersionTs int64 `json:"versionts"` + DoneReason string `json:"donereason,omitempty"` +} diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index f59416999b..2777cd17f1 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1503,3 +1503,7 @@ func (ws *WshServer) JobControllerAttachJobCommand(ctx context.Context, data wsh func (ws *WshServer) JobControllerDetachJobCommand(ctx context.Context, jobId string) error { return jobcontroller.DetachJobFromBlock(ctx, jobId, true) } + +func (ws *WshServer) BlockJobStatusCommand(ctx context.Context, blockId string) (*wshrpc.BlockJobStatusData, error) { + return jobcontroller.GetBlockJobStatus(ctx, blockId) +} From 9eedfe86f982bddbe48fa10aeb59c28bd51f3f39 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 2 Feb 2026 20:20:38 -0800 Subject: [PATCH 16/34] checkpoint --- frontend/types/gotypes.d.ts | 3 +++ pkg/blockcontroller/durableshellcontroller.go | 7 +------ pkg/blockcontroller/shellcontroller.go | 7 +------ pkg/jobcontroller/jobcontroller.go | 6 ++++++ pkg/util/shellutil/shellutil.go | 13 +++++++++++++ pkg/wshrpc/wshrpctypes.go | 13 ++++++++----- 6 files changed, 32 insertions(+), 17 deletions(-) diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c8e59c7c10..b2a7f9a7ff 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -147,6 +147,9 @@ declare global { status: null | "init" | "connected" | "disconnected" | "done"; versionts: number; donereason?: string; + cmdexitts?: number; + cmdexitcode?: number; + cmdexitsignal?: string; }; // wshrpc.BlocksListEntry diff --git a/pkg/blockcontroller/durableshellcontroller.go b/pkg/blockcontroller/durableshellcontroller.go index e48974d6b4..6dcc260d60 100644 --- a/pkg/blockcontroller/durableshellcontroller.go +++ b/pkg/blockcontroller/durableshellcontroller.go @@ -306,12 +306,7 @@ func (dsc *DurableShellController) resetTerminalState(logCtx context.Context) { blocklogger.Debugf(logCtx, "[conndebug] resetTerminalState: resetting terminal state for job\n") - resetSeq := "\x1b[0m" // reset attributes - resetSeq += "\x1b[?25h" // show cursor - resetSeq += "\x1b[?1000l" // disable mouse tracking - resetSeq += "\x1b[?1007l" // disable alternate scroll mode - resetSeq += "\x1b[?2004l" // disable bracketed paste mode - resetSeq += shellutil.FormatOSC(16162, "R") // disable alternate screen mode + resetSeq := shellutil.GetTerminalResetSeq() resetSeq += "\r\n\r\n" err := filestore.WFS.AppendData(ctx, jobId, jobcontroller.JobOutputFileName, []byte(resetSeq)) diff --git a/pkg/blockcontroller/shellcontroller.go b/pkg/blockcontroller/shellcontroller.go index de8a9c2013..9cb721d02c 100644 --- a/pkg/blockcontroller/shellcontroller.go +++ b/pkg/blockcontroller/shellcontroller.go @@ -210,12 +210,7 @@ func (sc *ShellController) resetTerminalState(logCtx context.Context) { blocklogger.Debugf(logCtx, "[conndebug] resetTerminalState: resetting terminal state\n") // controller type = "shell" var buf bytes.Buffer - buf.WriteString("\x1b[0m") // reset attributes - buf.WriteString("\x1b[?25h") // show cursor - buf.WriteString("\x1b[?1000l") // disable mouse tracking - buf.WriteString("\x1b[?1007l") // disable alternate scroll mode - buf.WriteString("\x1b[?2004l") // disable bracketed paste mode - buf.WriteString(shellutil.FormatOSC(16162, "R")) // OSC 16162 "R" - disable alternate screen mode (only if active), reset "shell integration" status. + buf.WriteString(shellutil.GetTerminalResetSeq()) buf.WriteString("\r\n\r\n") err := HandleAppendBlockFile(sc.BlockId, wavebase.BlockFile_Term, buf.Bytes()) if err != nil { diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index fb392fb240..ee5092fb34 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -142,6 +142,9 @@ func GetBlockJobStatus(ctx context.Context, blockId string) (*wshrpc.BlockJobSta data.JobId = job.OID data.DoneReason = job.JobManagerDoneReason + data.CmdExitTs = job.CmdExitTs + data.CmdExitCode = job.CmdExitCode + data.CmdExitSignal = job.CmdExitSignal if job.JobManagerStatus == JobManagerStatus_Init { data.Status = "init" @@ -643,15 +646,18 @@ func runOutputLoop(ctx context.Context, jobId string, reader *streamclient.Reade } func HandleCmdJobExited(ctx context.Context, jobId string, data wshrpc.CommandJobCmdExitedData) error { + var updatedJob *waveobj.Job err := wstore.DBUpdateFn(ctx, jobId, func(job *waveobj.Job) { job.CmdExitError = data.ExitErr job.CmdExitCode = data.ExitCode job.CmdExitSignal = data.ExitSignal job.CmdExitTs = data.ExitTs + updatedJob = job }) if err != nil { return fmt.Errorf("failed to update job exit status: %w", err) } + sendBlockJobStatusEventByJob(ctx, updatedJob) tryTerminateJobManager(ctx, jobId) return nil } diff --git a/pkg/util/shellutil/shellutil.go b/pkg/util/shellutil/shellutil.go index 265fdd5fff..6a78a67d53 100644 --- a/pkg/util/shellutil/shellutil.go +++ b/pkg/util/shellutil/shellutil.go @@ -619,6 +619,19 @@ func FixupWaveZshHistory() error { return nil } +func GetTerminalResetSeq() string { + resetSeq := "\x1b[0m" // reset attributes + resetSeq += "\x1b[?25h" // show cursor + resetSeq += "\x1b[?1l" // normal cursor keys + resetSeq += "\x1b[?7h" // wraparound on + resetSeq += "\x1b[?1000l" // disable mouse tracking + resetSeq += "\x1b[?1007l" // disable alternate scroll mode + resetSeq += "\x1b[?1004l" // disable focus reporting (FocusIn/FocusOut) + resetSeq += "\x1b[?2004l" // disable bracketed paste mode + resetSeq += FormatOSC(16162, "R") // disable alternate screen mode + return resetSeq +} + func FormatOSC(oscNum int, parts ...string) string { if len(parts) == 0 { return fmt.Sprintf("\x1b]%d\x07", oscNum) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 1da0a2280e..f6ec49852b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -866,9 +866,12 @@ type TabIndicatorEventData struct { } type BlockJobStatusData struct { - BlockId string `json:"blockid"` - JobId string `json:"jobid"` - Status string `json:"status" tstype:"null | \"init\" | \"connected\" | \"disconnected\" | \"done\""` - VersionTs int64 `json:"versionts"` - DoneReason string `json:"donereason,omitempty"` + BlockId string `json:"blockid"` + JobId string `json:"jobid"` + Status string `json:"status" tstype:"null | \"init\" | \"connected\" | \"disconnected\" | \"done\""` + VersionTs int64 `json:"versionts"` + DoneReason string `json:"donereason,omitempty"` + CmdExitTs int64 `json:"cmdexitts,omitempty"` + CmdExitCode *int `json:"cmdexitcode,omitempty"` + CmdExitSignal string `json:"cmdexitsignal,omitempty"` } From bac8699b31d18aa18b15670ac93f068a707c198c Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 2 Feb 2026 21:55:11 -0800 Subject: [PATCH 17/34] checkpoint -- terminal messages on shell exit, better signal handling --- pkg/blockcontroller/durableshellcontroller.go | 45 +------ pkg/jobcontroller/jobcontroller.go | 111 ++++++++++++++++++ pkg/jobmanager/jobcmd.go | 4 +- pkg/jobmanager/jobmanager_unix.go | 50 ++++---- pkg/jobmanager/jobmanager_windows.go | 9 +- pkg/shellexec/shellexec.go | 3 +- 6 files changed, 151 insertions(+), 71 deletions(-) diff --git a/pkg/blockcontroller/durableshellcontroller.go b/pkg/blockcontroller/durableshellcontroller.go index 6dcc260d60..7ac96b0e19 100644 --- a/pkg/blockcontroller/durableshellcontroller.go +++ b/pkg/blockcontroller/durableshellcontroller.go @@ -7,14 +7,11 @@ import ( "context" "encoding/base64" "fmt" - "io/fs" "log" "sync" "time" "github.com/google/uuid" - "github.com/wavetermdev/waveterm/pkg/blocklogger" - "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/jobcontroller" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" @@ -165,11 +162,6 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj. return fmt.Errorf("failed to start new job: %w", err) } jobId = newJobId - - err = jobcontroller.AttachJobToBlock(ctx, jobId, dsc.BlockId) - if err != nil { - log.Printf("error attaching job to block: %v\n", err) - } } dsc.WithLock(func() { @@ -273,44 +265,9 @@ func (dsc *DurableShellController) startNewJob(ctx context.Context, blockMeta wa SwapToken: swapToken, ForceJwt: blockMeta.GetBool(waveobj.MetaKey_CmdJwt, false), } - jobId, err := shellexec.StartRemoteShellJob(ctx, ctx, termSize, cmdStr, cmdOpts, conn) + jobId, err := shellexec.StartRemoteShellJob(ctx, ctx, termSize, cmdStr, cmdOpts, conn, dsc.BlockId) if err != nil { return "", fmt.Errorf("failed to start remote shell job: %w", err) } return jobId, nil } - -func (dsc *DurableShellController) resetTerminalState(logCtx context.Context) { - ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cancelFn() - - jobId := "" - dsc.WithLock(func() { - jobId = dsc.JobId - }) - if jobId == "" { - return - } - - wfile, statErr := filestore.WFS.Stat(ctx, jobId, jobcontroller.JobOutputFileName) - if statErr == fs.ErrNotExist { - return - } - if statErr != nil { - log.Printf("error statting job output file: %v\n", statErr) - return - } - if wfile.Size == 0 { - return - } - - blocklogger.Debugf(logCtx, "[conndebug] resetTerminalState: resetting terminal state for job\n") - - resetSeq := shellutil.GetTerminalResetSeq() - resetSeq += "\r\n\r\n" - - err := filestore.WFS.AppendData(ctx, jobId, jobcontroller.JobOutputFileName, []byte(resetSeq)) - if err != nil { - log.Printf("error appending terminal reset to job file: %v\n", err) - } -} diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index ee5092fb34..3542e97648 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -8,17 +8,20 @@ import ( "encoding/base64" "fmt" "io" + "io/fs" "log" "strings" "sync" "time" "github.com/google/uuid" + "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/filestore" "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/streamclient" "github.com/wavetermdev/waveterm/pkg/util/envutil" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/utilds" "github.com/wavetermdev/waveterm/pkg/wavejwt" @@ -31,6 +34,8 @@ import ( "github.com/wavetermdev/waveterm/pkg/wstore" ) +const DefaultTimeout = 2 * time.Second + const ( JobManagerStatus_Init = "init" JobManagerStatus_Running = "running" @@ -423,6 +428,7 @@ type StartJobParams struct { Args []string Env map[string]string TermSize *waveobj.TermSize + BlockId string } func StartJob(ctx context.Context, params StartJobParams) (string, error) { @@ -468,6 +474,7 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { CmdTermSize: *params.TermSize, JobAuthToken: jobAuthToken, JobManagerStatus: JobManagerStatus_Init, + AttachedBlockId: params.BlockId, Meta: make(waveobj.MetaMapType), } @@ -514,6 +521,8 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { Timeout: 30000, } + writeSessionSeparatorToTerminal(params.BlockId, params.TermSize.Cols) + log.Printf("[job:%s] sending RemoteStartJobCommand to connection %s, cmd=%q, args=%v", jobId, params.ConnName, params.Cmd, params.Args) log.Printf("[job:%s] env=%v", jobId, params.Env) rtnData, err := wshclient.RemoteStartJobCommand(bareRpc, startJobData, rpcOpts) @@ -659,6 +668,17 @@ func HandleCmdJobExited(ctx context.Context, jobId string, data wshrpc.CommandJo } sendBlockJobStatusEventByJob(ctx, updatedJob) tryTerminateJobManager(ctx, jobId) + + // the output file shouldn't be empty since this is a real termination. + // even if it is, we still want to write the exit codes + resetTerminalState(ctx, updatedJob.AttachedBlockId) + msg := "shell terminated" + if updatedJob.CmdExitCode != nil && *updatedJob.CmdExitCode != 0 { + msg = fmt.Sprintf("shell terminated (exit code %d)", *updatedJob.CmdExitCode) + } else if updatedJob.CmdExitSignal != "" { + msg = fmt.Sprintf("shell terminated (signal %s)", updatedJob.CmdExitSignal) + } + writeMutedMessageToTerminal(updatedJob.AttachedBlockId, "["+msg+"]") return nil } @@ -1203,3 +1223,94 @@ func SendInput(ctx context.Context, data wshrpc.CommandJobInputData) error { return nil } + +func resetTerminalState(logCtx context.Context, blockId string) { + if blockId == "" { + return + } + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + if isFileEmpty(ctx, blockId) { + return + } + blocklogger.Debugf(logCtx, "[conndebug] resetTerminalState: resetting terminal state for block\n") + resetSeq := shellutil.GetTerminalResetSeq() + resetSeq += "\r\n" + err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(resetSeq)) + if err != nil { + log.Printf("error appending terminal reset to block file: %v\n", err) + } +} + +func isFileEmpty(ctx context.Context, blockId string) bool { + if blockId == "" { + return true + } + file, statErr := filestore.WFS.Stat(ctx, blockId, JobOutputFileName) + if statErr == fs.ErrNotExist { + return true + } + if statErr != nil { + log.Printf("error statting block output file: %v\n", statErr) + return true + } + return file.Size == 0 +} + +const SimpleSeparator = true + +func writeSessionSeparatorToTerminal(blockId string, termWidth int) { + if blockId == "" { + return + } + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + if isFileEmpty(ctx, blockId) { + return + } + var separatorLine string + if SimpleSeparator { + separatorLine = "\r\n" + } else { + + separatorWidth := 20 + if termWidth < separatorWidth { + separatorWidth = termWidth + } + separatorChars := strings.Repeat("─", separatorWidth) + separatorLine = "\x1b[90m" + separatorChars + "\x1b[0m\r\n\r\n" + } + err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(separatorLine)) + if err != nil { + log.Printf("error writing session separator to terminal (blockid=%s): %v", blockId, err) + } +} + +// msg should not have a terminating newline +func writeWaveMessageToTerminal(blockId string, msg string) { + if blockId == "" { + return + } + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + waveMsg := "\x1b[0m\x1b[1;32m" + "[wave] " + "\x1b[0m" + fullMsg := waveMsg + msg + "\r\n" + err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(fullMsg)) + if err != nil { + log.Printf("error writing message to terminal (blockid=%s): %v", blockId, err) + } +} + +// msg should not have a terminating newline +func writeMutedMessageToTerminal(blockId string, msg string) { + if blockId == "" { + return + } + ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) + defer cancelFn() + fullMsg := "\x1b[90m" + msg + "\x1b[0m\r\n" + err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(fullMsg)) + if err != nil { + log.Printf("error writing muted message to terminal (blockid=%s): %v", blockId, err) + } +} diff --git a/pkg/jobmanager/jobcmd.go b/pkg/jobmanager/jobcmd.go index 7102fd77fd..2063b607cd 100644 --- a/pkg/jobmanager/jobcmd.go +++ b/pkg/jobmanager/jobcmd.go @@ -86,7 +86,7 @@ func (jm *JobCmd) waitForProcess() { if exitErr, ok := err.(*exec.ExitError); ok { if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { if status.Signaled() { - jm.exitSignal = status.Signal().String() + jm.exitSignal = getSignalName(status.Signal()) } else if status.Exited() { code := status.ExitStatus() jm.exitCode = &code @@ -197,7 +197,7 @@ func (jm *JobCmd) HandleInput(data wshrpc.CommandJobInputData) error { } if data.SigName != "" { - sig := normalizeSignal(data.SigName) + sig := parseSignal(data.SigName) if sig != nil && jm.cmd.Process != nil { err := jm.cmd.Process.Signal(sig) if err != nil { diff --git a/pkg/jobmanager/jobmanager_unix.go b/pkg/jobmanager/jobmanager_unix.go index 46cd0deec5..a5128e13f2 100644 --- a/pkg/jobmanager/jobmanager_unix.go +++ b/pkg/jobmanager/jobmanager_unix.go @@ -11,6 +11,7 @@ import ( "os" "os/signal" "path/filepath" + "strconv" "strings" "syscall" @@ -25,32 +26,35 @@ func getProcessGroupId(pid int) (int, error) { return pgid, nil } -func normalizeSignal(sigName string) os.Signal { +func parseSignal(sigName string) os.Signal { + sigName = strings.TrimSpace(sigName) sigName = strings.ToUpper(sigName) - sigName = strings.TrimPrefix(sigName, "SIG") - - switch sigName { - case "HUP": - return syscall.SIGHUP - case "INT": - return syscall.SIGINT - case "QUIT": - return syscall.SIGQUIT - case "KILL": - return syscall.SIGKILL - case "TERM": - return syscall.SIGTERM - case "USR1": - return syscall.SIGUSR1 - case "USR2": - return syscall.SIGUSR2 - case "STOP": - return syscall.SIGSTOP - case "CONT": - return syscall.SIGCONT - default: + if n, err := strconv.Atoi(sigName); err == nil { + return syscall.Signal(n) + } + if !strings.HasPrefix(sigName, "SIG") { + sigName = "SIG" + sigName + } + sig := unix.SignalNum(sigName) + if sig == 0 { return nil } + return sig +} + +func getSignalName(sig os.Signal) string { + if sig == nil { + return "" + } + scSig, ok := sig.(syscall.Signal) + if !ok { + return sig.String() + } + name := unix.SignalName(scSig) + if name == "" { + return fmt.Sprintf("%d", int(scSig)) + } + return name } func daemonize(clientId string, jobId string) error { diff --git a/pkg/jobmanager/jobmanager_windows.go b/pkg/jobmanager/jobmanager_windows.go index 356bfcb66e..166ef51729 100644 --- a/pkg/jobmanager/jobmanager_windows.go +++ b/pkg/jobmanager/jobmanager_windows.go @@ -14,10 +14,17 @@ func getProcessGroupId(pid int) (int, error) { return 0, fmt.Errorf("process group id not supported on windows") } -func normalizeSignal(sigName string) os.Signal { +func parseSignal(sigName string) os.Signal { return nil } +func getSignalName(sig os.Signal) string { + if sig == nil { + return "" + } + return sig.String() +} + func daemonize(clientId string, jobId string) error { return fmt.Errorf("daemonize not supported on windows") } diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 51f9a066c7..d15fa70d76 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -460,7 +460,7 @@ func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize return &ShellProc{Cmd: sessionWrap, ConnName: conn.GetName(), CloseOnce: &sync.Once{}, DoneCh: make(chan any)}, nil } -func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (string, error) { +func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn, optBlockId string) (string, error) { connRoute := wshutil.MakeConnectionRouteId(conn.GetName()) rpcClient := wshclient.GetBareRpcClient() remoteInfo, err := wshclient.RemoteGetInfoCommand(rpcClient, &wshrpc.RpcOpts{Route: connRoute, Timeout: 2000}) @@ -558,6 +558,7 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w Args: shellOpts, Env: env, TermSize: &termSize, + BlockId: optBlockId, } jobId, err := jobcontroller.StartJob(ctx, jobParams) if err != nil { From 786a8a8a0a3732d985f4fdfcfe1570c0e9ae7878 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 2 Feb 2026 23:03:16 -0800 Subject: [PATCH 18/34] fix startup issues, concurrent calls, force reconnections, etc. --- pkg/blockcontroller/durableshellcontroller.go | 24 ++++------- pkg/jobcontroller/jobcontroller.go | 43 +++++++++++++++++-- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/pkg/blockcontroller/durableshellcontroller.go b/pkg/blockcontroller/durableshellcontroller.go index 7ac96b0e19..762a35f710 100644 --- a/pkg/blockcontroller/durableshellcontroller.go +++ b/pkg/blockcontroller/durableshellcontroller.go @@ -142,16 +142,14 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj. if err != nil { return fmt.Errorf("error getting job manager status: %w", err) } - if status != jobcontroller.JobManagerStatus_Running { - if force { - log.Printf("block %q has jobId %s but manager is not running (status: %s), detaching (force=true)\n", dsc.BlockId, blockData.JobId, status) - jobcontroller.DetachJobFromBlock(ctx, blockData.JobId, false) - } else { - log.Printf("block %q has jobId %s but manager is not running (status: %s), not starting (force=false)\n", dsc.BlockId, blockData.JobId, status) - return nil - } - } else { + if status == jobcontroller.JobManagerStatus_Running { jobId = blockData.JobId + } else if !force { + log.Printf("block %q has jobId %s but manager is not running (status: %s), not starting (force=false)\n", dsc.BlockId, blockData.JobId, status) + return nil + } else { + log.Printf("block %q has jobId %s but manager is not running (status: %s), starting new job (force=true)\n", dsc.BlockId, blockData.JobId, status) + // intentionally leave jobId empty to trigger starting a new job below } } @@ -169,13 +167,9 @@ func (dsc *DurableShellController) Start(ctx context.Context, blockMeta waveobj. dsc.sendUpdate_withlock() }) - _, err = jobcontroller.CheckJobConnected(ctx, jobId) + err = jobcontroller.ReconnectJob(ctx, jobId, rtOpts) if err != nil { - log.Printf("job %s is not connected, attempting reconnect: %v\n", jobId, err) - err = jobcontroller.ReconnectJob(ctx, jobId, rtOpts) - if err != nil { - return fmt.Errorf("failed to reconnect to job: %w", err) - } + return fmt.Errorf("failed to reconnect to job: %w", err) } return nil diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index 3542e97648..02c0c8d6ab 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -32,6 +32,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wstore" + "golang.org/x/sync/singleflight" ) const DefaultTimeout = 2 * time.Second @@ -85,6 +86,8 @@ var ( m: make(map[string]*connState), reconcileCh: make(chan struct{}, 1), } + + reconnectGroup singleflight.Group ) func isJobManagerRunning(job *waveobj.Job) bool { @@ -482,8 +485,13 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { if err != nil { return "", fmt.Errorf("failed to create job in database: %w", err) } - sendBlockJobStatusEventByJob(ctx, job) - + if params.BlockId != "" { + // AttachJobToBlock will send status + err = AttachJobToBlock(ctx, jobId, params.BlockId) + if err != nil { + return "", fmt.Errorf("failed to attach job to block: %w", err) + } + } bareRpc := wshclient.GetBareRpcClient() broker := bareRpc.StreamBroker readerRouteId := wshclient.GetBareRpcClientRouteId() @@ -792,10 +800,25 @@ func remoteTerminateJobManager(ctx context.Context, job *waveobj.Job) error { } func ReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts) error { + _, err, _ := reconnectGroup.Do(jobId, func() (any, error) { + return nil, doReconnectJob(ctx, jobId, rtOpts) + }) + return err +} + +func doReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOpts) error { job, err := wstore.DBMustGet[*waveobj.Job](ctx, jobId) if err != nil { return fmt.Errorf("failed to get job: %w", err) } + + _, err = CheckJobConnected(ctx, jobId) + if err == nil { + log.Printf("[job:%s] already connected, skipping reconnect", jobId) + return nil + } + log.Printf("[job:%s] not connected, proceeding with reconnect: %v", jobId, err) + isConnected, err := conncontroller.IsConnected(job.Connection) if err != nil { return fmt.Errorf("error checking connection status: %w", err) @@ -1117,15 +1140,29 @@ func DeleteJob(ctx context.Context, jobId string) error { func AttachJobToBlock(ctx context.Context, jobId string, blockId string) error { err := wstore.WithTx(ctx, func(tx *wstore.TxWrap) error { + var oldJobId string + err := wstore.DBUpdateFn(tx.Context(), blockId, func(block *waveobj.Block) { + oldJobId = block.JobId block.JobId = jobId }) if err != nil { return fmt.Errorf("failed to update block: %w", err) } + if oldJobId != "" && oldJobId != jobId { + err = wstore.DBUpdateFn(tx.Context(), oldJobId, func(oldJob *waveobj.Job) { + if oldJob.AttachedBlockId == blockId { + oldJob.AttachedBlockId = "" + } + }) + if err != nil { + log.Printf("[job:%s] warning: could not detach old job: %v", oldJobId, err) + } + } + err = wstore.DBUpdateFnErr(tx.Context(), jobId, func(job *waveobj.Job) error { - if job.AttachedBlockId != "" { + if job.AttachedBlockId != "" && job.AttachedBlockId != blockId { return fmt.Errorf("job %s already attached to block %s", jobId, job.AttachedBlockId) } job.AttachedBlockId = blockId From 073e75a0f9d52be59d14a5735716bb222538efb6 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 2 Feb 2026 23:37:41 -0800 Subject: [PATCH 19/34] fix stale streams --- pkg/jobcontroller/jobcontroller.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index 02c0c8d6ab..9326cc0f26 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -20,6 +20,7 @@ import ( "github.com/wavetermdev/waveterm/pkg/panichandler" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/streamclient" + "github.com/wavetermdev/waveterm/pkg/util/ds" "github.com/wavetermdev/waveterm/pkg/util/envutil" "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/util/utilfn" @@ -87,6 +88,8 @@ var ( reconcileCh: make(chan struct{}, 1), } + jobStreamIds = ds.MakeSyncMap[string]() + reconnectGroup singleflight.Group ) @@ -497,6 +500,7 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { readerRouteId := wshclient.GetBareRpcClientRouteId() writerRouteId := wshutil.MakeJobRouteId(jobId) reader, streamMeta := broker.CreateStreamReader(readerRouteId, writerRouteId, DefaultStreamRwnd) + jobStreamIds.Set(jobId, streamMeta.Id) fileOpts := wshrpc.FileOpts{ MaxSize: 10 * 1024 * 1024, @@ -569,7 +573,7 @@ func StartJob(ctx context.Context, params StartJobParams) (string, error) { defer func() { panichandler.PanicHandler("jobcontroller:runOutputLoop", recover()) }() - runOutputLoop(context.Background(), jobId, reader) + runOutputLoop(context.Background(), jobId, streamMeta.Id, reader) }() return jobId, nil @@ -615,15 +619,20 @@ func handleAppendJobFile(ctx context.Context, jobId string, fileName string, dat return nil } -func runOutputLoop(ctx context.Context, jobId string, reader *streamclient.Reader) { +func runOutputLoop(ctx context.Context, jobId string, streamId string, reader *streamclient.Reader) { defer func() { - log.Printf("[job:%s] output loop finished", jobId) + log.Printf("[job:%s] [stream:%s] output loop finished", jobId, streamId) }() - log.Printf("[job:%s] output loop started", jobId) + log.Printf("[job:%s] [stream:%s] output loop started", jobId, streamId) buf := make([]byte, 4096) for { n, err := reader.Read(buf) + currentStreamId, _ := jobStreamIds.GetEx(jobId) + if currentStreamId != streamId { + log.Printf("[job:%s] [stream:%s] stream superseded by [stream:%s], exiting output loop", jobId, streamId, currentStreamId) + break + } if n > 0 { log.Printf("[job:%s] received %d bytes of data", jobId, n) appendErr := handleAppendJobFile(ctx, jobId, JobOutputFileName, buf[:n]) @@ -896,6 +905,7 @@ func doReconnectJob(ctx context.Context, jobId string, rtOpts *waveobj.RuntimeOp if err != nil { return fmt.Errorf("route did not establish after successful reconnection: %w", err) } + SetJobConnStatus(jobId, JobConnStatus_Connected) log.Printf("[job:%s] route established, restarting streaming", jobId) return restartStreaming(ctx, jobId, true, rtOpts) @@ -979,8 +989,8 @@ func restartStreaming(ctx context.Context, jobId string, knownConnected bool, rt broker := bareRpc.StreamBroker readerRouteId := wshclient.GetBareRpcClientRouteId() writerRouteId := wshutil.MakeJobRouteId(jobId) - reader, streamMeta := broker.CreateStreamReaderWithSeq(readerRouteId, writerRouteId, DefaultStreamRwnd, currentSeq) + jobStreamIds.Set(jobId, streamMeta.Id) prepareData := wshrpc.CommandJobPrepareConnectData{ StreamMeta: *streamMeta, @@ -1076,7 +1086,7 @@ func restartStreaming(ctx context.Context, jobId string, knownConnected bool, rt defer func() { panichandler.PanicHandler("jobcontroller:RestartStreaming:runOutputLoop", recover()) }() - runOutputLoop(context.Background(), jobId, reader) + runOutputLoop(context.Background(), jobId, streamMeta.Id, reader) }() log.Printf("[job:%s] streaming restarted successfully", jobId) From 5df6e534014abce697636dd18f82f4c9b63fd434 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 09:39:08 -0800 Subject: [PATCH 20/34] filp default durability off for now --- pkg/wconfig/defaultconfig/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index deffcc819a..9ccffde5a6 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -27,7 +27,7 @@ "term:bellsound": false, "term:bellindicator": true, "term:copyonselect": true, - "term:durable": true, + "term:durable": false, "waveai:showcloudmodes": true, "waveai:defaultmode": "waveai@balanced" } From b0c75b9be3ecdee4019328a330f382325d2500d2 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:05:44 -0800 Subject: [PATCH 21/34] fix prevMagnifiedState --- frontend/app/block/blockframe-header.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 0f0552c815..c3594d4b48 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -221,13 +221,11 @@ const BlockFrame_Header = ({ const connStatus = jotai.useAtomValue(getConnStatusAtom(connName)); React.useEffect(() => { - // this is an effect and "reactive" since mangification can happen via keyboard or button click - // this catches both methods - if (!magnified || preview || prevMagifiedState.current) { - return; + if (magnified && !preview && !prevMagifiedState.current) { + RpcApi.ActivityCommand(TabRpcClient, { nummagnify: 1 }); + recordTEvent("action:magnify", { "block:view": viewName }); } - RpcApi.ActivityCommand(TabRpcClient, { nummagnify: 1 }); - recordTEvent("action:magnify", { "block:view": viewName }); + prevMagifiedState.current = magnified; }, [magnified]); const viewIconElem = getViewIconElem(viewIconUnion, blockData); From 2ffd36aaf255e6969a1a770046bcd75a15c08f58 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:08:47 -0800 Subject: [PATCH 22/34] fix condition --- frontend/app/store/global.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 69d554e44c..2d64e8a7e0 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -442,7 +442,7 @@ function getBlockTermDurableAtom(blockId: string): Atom { } // Check if view is "term", and controller is "shell" - if (block.meta?.view != "term" && block.meta?.controller == "shell") { + if (block.meta?.view != "term" || block.meta?.controller != "shell") { return false; } From 6a8d0309fe6d691ed0889bf5bd49052cfa4e2c65 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:08:56 -0800 Subject: [PATCH 23/34] add blockid to dep array --- frontend/app/block/connstatusoverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/block/connstatusoverlay.tsx b/frontend/app/block/connstatusoverlay.tsx index de18cb8ae8..5bf19b8d40 100644 --- a/frontend/app/block/connstatusoverlay.tsx +++ b/frontend/app/block/connstatusoverlay.tsx @@ -50,7 +50,7 @@ export const ConnStatusOverlay = React.memo( { timeout: 60000 } ); prtn.catch((e) => console.log("error reconnecting", connName, e)); - }, [connName]); + }, [connName, nodeModel.blockId]); const handleDisableWsh = React.useCallback(async () => { const metamaptype: unknown = { From eed18dc7224cbc4e340a73adeb158b8205d4ae03 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:14:55 -0800 Subject: [PATCH 24/34] add sorting state to setselectedpath reaction --- frontend/app/view/preview/preview-directory.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index affd6e8069..b0cb95e922 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -234,11 +234,11 @@ function DirectoryTable({ newDirectory, }, }); - + const sortingState = table.getState().sorting; useEffect(() => { const allRows = table.getRowModel()?.flatRows || []; setSelectedPath((allRows[focusIndex]?.getValue("path") as string) ?? null); - }, [focusIndex, data, setSelectedPath]); + }, [focusIndex, data, setSelectedPath, sortingState]); const columnSizeVars = useMemo(() => { const headers = table.getFlatHeaders(); From 15c8e63a33b6b4449de231358c976121daabf907 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:18:09 -0800 Subject: [PATCH 25/34] catch error --- frontend/app/view/term/term-model.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 887f08123c..33e3d3439e 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -357,9 +357,13 @@ export class TermViewModel implements ViewModel { this.blockJobStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.blockJobStatusVersionTs = 0; const initialBlockJobStatus = RpcApi.BlockJobStatusCommand(TabRpcClient, blockId); - initialBlockJobStatus.then((status) => { - this.handleBlockJobStatusUpdate(status); - }); + initialBlockJobStatus + .then((status) => { + this.handleBlockJobStatusUpdate(status); + }) + .catch((error) => { + console.log("error getting initial block job status", error); + }); this.blockJobStatusUnsubFn = waveEventSubscribe({ eventType: "block:jobstatus", scope: `block:${blockId}`, From c1281341812729722c0f5fb859e9459cc899df57 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:18:15 -0800 Subject: [PATCH 26/34] fix condition --- pkg/jobcontroller/jobcontroller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index 9326cc0f26..f8074c3c26 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -1100,7 +1100,7 @@ func IsBlockTermDurable(block *waveobj.Block) bool { } // Check if view is "term", and controller is "shell" - if block.Meta.GetString(waveobj.MetaKey_View, "") != "term" && block.Meta.GetString(waveobj.MetaKey_Controller, "") == "shell" { + if block.Meta.GetString(waveobj.MetaKey_View, "") != "term" || block.Meta.GetString(waveobj.MetaKey_Controller, "") != "shell" { return false } From 1d0b159c47b423db20c5d70ac3db30cd7d7725b3 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:19:21 -0800 Subject: [PATCH 27/34] fix typing (remove any) --- frontend/builder/store/builder-apppanel-model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index 15a18e3ec3..0f5eed8371 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -310,7 +310,7 @@ export class BuilderAppPanelModel { this.focusElemRef.current = ref; } - setMonacoEditorRef(ref: any) { + setMonacoEditorRef(ref: MonacoTypes.editor.IStandaloneCodeEditor | null) { this.monacoEditorRef.current = ref; } From d1eca66dc8ab6e8b5244979127c8be932ec3515d Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:24:41 -0800 Subject: [PATCH 28/34] fix typing --- frontend/builder/tabs/builder-codetab.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/builder/tabs/builder-codetab.tsx b/frontend/builder/tabs/builder-codetab.tsx index 454f6013aa..8b5ff7a08f 100644 --- a/frontend/builder/tabs/builder-codetab.tsx +++ b/frontend/builder/tabs/builder-codetab.tsx @@ -8,7 +8,6 @@ import * as keyutil from "@/util/keyutil"; import { cn } from "@/util/util"; import { useAtomValue } from "jotai"; import type * as MonacoTypes from "monaco-editor"; -import * as Monaco from "monaco-editor"; import { memo, useEffect } from "react"; const BuilderCodeTab = memo(() => { @@ -32,7 +31,7 @@ const BuilderCodeTab = memo(() => { model.setCodeContent(newText); }; - const handleEditorMount = (editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof Monaco) => { + const handleEditorMount = (editor: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof MonacoTypes) => { model.setMonacoEditorRef(editor); return () => { model.setMonacoEditorRef(null); From c45e5da045e0947612c5b21a3c92fb73713b7e84 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:24:49 -0800 Subject: [PATCH 29/34] remove dead code --- pkg/jobcontroller/jobcontroller.go | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index f8074c3c26..ce38ecc4cf 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -1304,8 +1304,6 @@ func isFileEmpty(ctx context.Context, blockId string) bool { return file.Size == 0 } -const SimpleSeparator = true - func writeSessionSeparatorToTerminal(blockId string, termWidth int) { if blockId == "" { return @@ -1315,39 +1313,13 @@ func writeSessionSeparatorToTerminal(blockId string, termWidth int) { if isFileEmpty(ctx, blockId) { return } - var separatorLine string - if SimpleSeparator { - separatorLine = "\r\n" - } else { - - separatorWidth := 20 - if termWidth < separatorWidth { - separatorWidth = termWidth - } - separatorChars := strings.Repeat("─", separatorWidth) - separatorLine = "\x1b[90m" + separatorChars + "\x1b[0m\r\n\r\n" - } + separatorLine := "\r\n" err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(separatorLine)) if err != nil { log.Printf("error writing session separator to terminal (blockid=%s): %v", blockId, err) } } -// msg should not have a terminating newline -func writeWaveMessageToTerminal(blockId string, msg string) { - if blockId == "" { - return - } - ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cancelFn() - waveMsg := "\x1b[0m\x1b[1;32m" + "[wave] " + "\x1b[0m" - fullMsg := waveMsg + msg + "\r\n" - err := doWFSAppend(ctx, waveobj.MakeORef(waveobj.OType_Block, blockId), JobOutputFileName, []byte(fullMsg)) - if err != nil { - log.Printf("error writing message to terminal (blockid=%s): %v", blockId, err) - } -} - // msg should not have a terminating newline func writeMutedMessageToTerminal(blockId string, msg string) { if blockId == "" { From 814132cfc1611554915668c0a907027a83dae3b3 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 10:25:04 -0800 Subject: [PATCH 30/34] reject invalid integer signals (negative) --- pkg/jobmanager/jobmanager_unix.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/jobmanager/jobmanager_unix.go b/pkg/jobmanager/jobmanager_unix.go index a5128e13f2..a98a4ac12e 100644 --- a/pkg/jobmanager/jobmanager_unix.go +++ b/pkg/jobmanager/jobmanager_unix.go @@ -30,6 +30,9 @@ func parseSignal(sigName string) os.Signal { sigName = strings.TrimSpace(sigName) sigName = strings.ToUpper(sigName) if n, err := strconv.Atoi(sigName); err == nil { + if n <= 0 { + return nil + } return syscall.Signal(n) } if !strings.HasPrefix(sigName, "SIG") { From 262958987fcd4002185c995eaa9f60805aae6dab Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 11:18:22 -0800 Subject: [PATCH 31/34] null check blockdata --- frontend/app/block/connstatusoverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/block/connstatusoverlay.tsx b/frontend/app/block/connstatusoverlay.tsx index 5bf19b8d40..8f0852abac 100644 --- a/frontend/app/block/connstatusoverlay.tsx +++ b/frontend/app/block/connstatusoverlay.tsx @@ -26,7 +26,7 @@ export const ConnStatusOverlay = React.memo( }) => { const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); const [connModalOpen] = jotai.useAtom(changeConnModalAtom); - const connName = blockData.meta?.connection; + const connName = blockData?.meta?.connection; const connStatus = jotai.useAtomValue(getConnStatusAtom(connName)); const isLayoutMode = jotai.useAtomValue(atoms.controlShiftDelayAtom); const [overlayRefCallback, _, domRect] = useDimensionsWithCallbackRef(30); From 0c3d1f3a4221e63961066ef5aff7159167a5fbb2 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 11:18:43 -0800 Subject: [PATCH 32/34] add advanced "restart in standard mode" option --- frontend/app/view/term/term-model.ts | 32 ++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 33e3d3439e..cdb860848b 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -735,6 +735,24 @@ export class TermViewModel implements ViewModel { prtn.catch((e) => console.log("error controller resync (force restart)", e)); } + async restartSessionInStandardMode() { + await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", this.blockId), + meta: { "term:durable": false }, + }); + await RpcApi.ControllerDestroyCommand(TabRpcClient, this.blockId); + const termsize = { + rows: this.termRef.current?.terminal?.rows, + cols: this.termRef.current?.terminal?.cols, + }; + await RpcApi.ControllerResyncCommand(TabRpcClient, { + tabid: globalStore.get(atoms.staticTabId), + blockid: this.blockId, + forcerestart: true, + rtopts: { termsize: termsize }, + }); + } + getContextMenuItems(): ContextMenuItem[] { const menu: ContextMenuItem[] = []; const hasSelection = this.termRef.current?.terminal?.hasSelection(); @@ -1122,6 +1140,20 @@ export class TermViewModel implements ViewModel { }, ], }); + + const isDurable = globalStore.get(getBlockTermDurableAtom(this.blockId)); + if (isDurable) { + advancedSubmenu.push({ + label: "Session Durability", + submenu: [ + { + label: "Restart Session in Standard Mode", + click: () => this.restartSessionInStandardMode(), + }, + ], + }); + } + fullMenu.push({ label: "Advanced", submenu: advancedSubmenu, From 4f956997ce2dec29e2ea68e000d57aee391c9c70 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 11:25:10 -0800 Subject: [PATCH 33/34] add reader close --- pkg/jobcontroller/jobcontroller.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/jobcontroller/jobcontroller.go b/pkg/jobcontroller/jobcontroller.go index ce38ecc4cf..0a6b84893f 100644 --- a/pkg/jobcontroller/jobcontroller.go +++ b/pkg/jobcontroller/jobcontroller.go @@ -620,6 +620,7 @@ func handleAppendJobFile(ctx context.Context, jobId string, fileName string, dat } func runOutputLoop(ctx context.Context, jobId string, streamId string, reader *streamclient.Reader) { + defer reader.Close() defer func() { log.Printf("[job:%s] [stream:%s] output loop finished", jobId, streamId) }() From c607e429288cb14d08798409f14bedac8bc920b9 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 3 Feb 2026 11:26:56 -0800 Subject: [PATCH 34/34] remove dead code --- frontend/app/view/term/term-model.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index cdb860848b..ea69fa14ea 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -108,9 +108,6 @@ export class TermViewModel implements ViewModel { if (termMode == "vdom") { return { elemtype: "iconbutton", icon: "bolt" }; } - const isCmd = get(this.isCmdController); - if (isCmd) { - } return { elemtype: "iconbutton", icon: "terminal" }; }); this.viewName = jotai.atom((get) => { @@ -1140,7 +1137,7 @@ export class TermViewModel implements ViewModel { }, ], }); - + const isDurable = globalStore.get(getBlockTermDurableAtom(this.blockId)); if (isDurable) { advancedSubmenu.push({ @@ -1153,7 +1150,7 @@ export class TermViewModel implements ViewModel { ], }); } - + fullMenu.push({ label: "Advanced", submenu: advancedSubmenu,