Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions desktop/frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -341,15 +341,32 @@ function showToast(msg: string) {
}, 2000);
}

function sweepMissingSessions() {
// snapshotKnownSessions captures id → SessionInfo for everything we currently
// know about (local + remote). Used before applying a new session list so
// sweepMissingSessions can stash the last-known info on a pane right before
// nulling its sessionId — keeps the tab label meaningful across a transient
// host disconnect.
function snapshotKnownSessions(): Map<string, SessionInfo> {
const m = new Map<string, SessionInfo>();
for (const s of localList.value) m.set(s.id, s);
for (const s of remoteList.value) m.set(s.id, s);
return m;
}

function sweepMissingSessions(snapshot?: Map<string, SessionInfo>) {
const localIds = new Set(localList.value.map((s) => s.id));
const remoteIds = new Set(remoteList.value.map((s) => s.id));
for (const t of tabs.value) {
for (let i = 0; i < t.panes.length; i++) {
const p = t.panes[i];
if (!p.sessionId) continue;
if (p.remote ? !remoteIds.has(p.sessionId) : !localIds.has(p.sessionId)) {
t.panes[i] = { sessionId: null, remote: p.remote };
// Stash the SessionInfo we had a moment ago so the TabBar can still
// show a useful title. Fall back to whatever the pane already cached
// (covers the case where two consecutive sweeps fire before the host
// comes back).
const lastSeenInfo = snapshot?.get(p.sessionId) ?? p.lastSeenInfo;
t.panes[i] = { sessionId: null, remote: p.remote, lastSeenInfo };
}
}
}
Expand All @@ -361,16 +378,18 @@ function refreshVisibleRemoteSessions() {
}

function applyLocalSessions(sessions: SessionInfo[]) {
const snap = snapshotKnownSessions();
localList.value = sessions;
refreshVisibleRemoteSessions();
sweepMissingSessions();
sweepMissingSessions(snap);
if (status.value !== "ready") status.value = "ready";
}

function applyRemoteSessions(sessions: SessionInfo[]) {
const snap = snapshotKnownSessions();
remoteRawList.value = sessions;
refreshVisibleRemoteSessions();
sweepMissingSessions();
sweepMissingSessions(snap);
}

function connectLocalSessionList(endpoint: Endpoint) {
Expand Down Expand Up @@ -637,12 +656,17 @@ const tabSummaries = computed(() =>
tabs.value.map((t) => {
const active = t.panes[t.activePaneIdx];
const info = active?.sessionId ? findSessionInfo(active.sessionId, active.remote) ?? null : null;
// When a remote host briefly drops, sweepMissingSessions nulls the
// pane's sessionId but stashes lastSeenInfo. Surface it as the tab's
// activeSession + disconnected flag so the label stays meaningful.
const fallback = !info && active?.lastSeenInfo ? active.lastSeenInfo : null;
return {
id: t.id,
layout: t.layout,
activeSession: info,
activeSession: info ?? fallback,
activeRemote: !!active?.remote,
paneCount: t.panes.length,
disconnected: !info && !!fallback,
};
}),
);
Expand Down
32 changes: 32 additions & 0 deletions desktop/frontend/src/components/TabBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,38 @@ afterEach(() => {
});

describe("TabBar", () => {
it("renders the lastSeenInfo title with a disconnected style after a remote drop", async () => {
await initI18n({
loadPreference: async () => "zh-CN",
getLanguages: () => ["zh-CN"],
listenLanguageChange: () => () => undefined,
});

const wrapper = mount(TabBar, {
props: {
tabs: [{
id: "tab-9",
layout: "single" as const,
activeSession: {
id: "stale-id", command: "powershell", cwd: "C:\\Users\\xianj",
title: "", cols: 80, rows: 24, started_at: 0,
},
activeRemote: true,
paneCount: 1,
disconnected: true,
}],
currentId: "tab-9",
starting: false,
},
});

// Title falls back to the last-known cwd basename, not "(空)".
expect(wrapper.text()).toContain("xianj");
expect(wrapper.text()).not.toContain("(空)");
// The tab itself is marked disconnected for styling.
expect(wrapper.get(".tab").classes()).toContain("disconnected");
});

it("localizes split layout icon titles", async () => {
await initI18n({
loadPreference: async () => "zh-CN",
Expand Down
9 changes: 6 additions & 3 deletions desktop/frontend/src/components/TabBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface TabSummary {
activeSession: SessionInfo | null;
activeRemote: boolean;
paneCount: number;
disconnected?: boolean;
}

defineProps<{
Expand Down Expand Up @@ -68,13 +69,13 @@ function onClose(e: MouseEvent, id: string) {
v-for="(t, idx) in tabs"
:key="t.id"
class="tab"
:class="{ active: t.id === currentId, remote: t.activeRemote }"
:title="(t.activeRemote ? i18nT('terminal.remotePrefix') : '') + (t.activeSession?.command ?? '')"
:class="{ active: t.id === currentId, remote: t.activeRemote, disconnected: t.disconnected }"
:title="(t.activeRemote ? i18nT('terminal.remotePrefix') : '') + (t.disconnected ? i18nT('terminal.tabDisconnectedSuffix') + ' ' : '') + (t.activeSession?.command ?? '')"
@click="emit('activate', t.id)"
>
<span class="num">{{ idx + 1 }}:</span>
<span v-if="t.layout !== 'single'" class="layout-icon" :title="layoutTitle(t)">{{ layoutLabel(t) }}</span>
<span v-else-if="t.activeRemote" class="dot remote-dot">●</span>
<span v-else-if="t.activeRemote" class="dot remote-dot" :class="{ disconnected: t.disconnected }">●</span>
<span v-else class="dot">●</span>
<span class="title">{{ shortTitle(t.activeSession) }}</span>
<button class="close" @click="onClose($event, t.id)">×</button>
Expand Down Expand Up @@ -121,6 +122,8 @@ function onClose(e: MouseEvent, id: string) {
.tab.active .num { color: var(--accent); }
.tab .dot { font-size: 9px; color: var(--good); }
.tab .remote-dot { color: #d29922; }
.tab .remote-dot.disconnected { color: var(--fg-dim); }
.tab.disconnected .title { color: var(--fg-dim); font-style: italic; }
.tab .layout-icon {
font-size: 11px; color: var(--fg-dim);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const en = {
remotePrefix: "[remote] ",
shellFallback: "shell",
emptyTab: "(empty)",
tabDisconnectedSuffix: "(disconnected)",
closePaneTitle: "close pane (Cmd+W / Ctrl+W)",
remoteViewerWatching: "{count} remote viewer(s) watching",
remoteSessionsAvailable: "{count} remote session(s) available",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const zhCN = {
remotePrefix: "[远端] ",
shellFallback: "shell",
emptyTab: "(空)",
tabDisconnectedSuffix: "(已断开)",
closePaneTitle: "关闭面板 (Cmd+W / Ctrl+W)",
remoteViewerWatching: "{count} 个远端查看者正在观看",
remoteSessionsAvailable: "{count} 个远端会话可用",
Expand Down
8 changes: 8 additions & 0 deletions desktop/frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Shared types for the per-tab pane-split layout model. See
// docs/superpowers/specs/2026-05-10-pane-split-layouts-design.md.

import type { SessionInfo } from "./connection";

export type LayoutKind = "single" | "vertical" | "horizontal" | "grid2x2";

// Direction the user requests when invoking a split shortcut. Only meaningful
Expand All @@ -17,6 +19,12 @@ export interface Pane {
// after a close).
sessionId: string | null;
remote: boolean;
// lastSeenInfo carries the most recent SessionInfo we saw for this pane
// before sweepMissingSessions nulled sessionId. Used so the TabBar can
// still show a meaningful title ("C:\\Users\\xianj — disconnected")
// instead of "(空)" when a remote host briefly drops, and so the user
// can tell which tab was which.
lastSeenInfo?: SessionInfo;
}

export interface Tab {
Expand Down
Loading