mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
feat(control-ui): reattach terminal sessions after reload/reconnect with replay
This commit is contained in:
@@ -314,6 +314,83 @@ describe("TerminalConnection", () => {
|
||||
expect(conn.size).toBe(2);
|
||||
});
|
||||
|
||||
it("attach replays the buffer before events that raced ahead, then resumes live", async () => {
|
||||
const client = makeFakeClient();
|
||||
const conn = new TerminalConnection(client);
|
||||
const data: string[] = [];
|
||||
let resolveAttach: (() => void) | undefined;
|
||||
client.request = ((method: string, params: unknown) => {
|
||||
client.requests.push({ method, params });
|
||||
if (method === "terminal.attach") {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
resolveAttach = () =>
|
||||
resolve({
|
||||
sessionId: "s1",
|
||||
agentId: "main",
|
||||
shell: "/bin/zsh",
|
||||
cwd: "/work",
|
||||
confined: false,
|
||||
buffer: "replayed history",
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.resolve({});
|
||||
}) as typeof client.request;
|
||||
|
||||
const attachPromise = conn.attach("s1", {
|
||||
onData: (d) => data.push(d),
|
||||
onExit: () => {},
|
||||
});
|
||||
// Post-snapshot bytes the server emits between rebind and the response.
|
||||
client.emit("terminal.data", { sessionId: "s1", seq: 5, data: " tail" });
|
||||
expect(data).toEqual([]);
|
||||
|
||||
resolveAttach?.();
|
||||
const result = await attachPromise;
|
||||
expect(result.buffer).toBe("replayed history");
|
||||
expect(client.requests[0]).toEqual({
|
||||
method: "terminal.attach",
|
||||
params: { sessionId: "s1" },
|
||||
});
|
||||
// Buffer first, then the raced event, then live data.
|
||||
client.emit("terminal.data", { sessionId: "s1", seq: 6, data: " live" });
|
||||
expect(data).toEqual(["replayed history", " tail", " live"]);
|
||||
});
|
||||
|
||||
it("drops the listener when an attach fails so failures do not leak subscriptions", async () => {
|
||||
const client = makeFakeClient();
|
||||
const conn = new TerminalConnection(client);
|
||||
client.request = ((method: string, params: unknown) => {
|
||||
client.requests.push({ method, params });
|
||||
// Expired/unknown session after the detach grace period.
|
||||
return Promise.reject(new Error("unknown terminal session"));
|
||||
}) as typeof client.request;
|
||||
|
||||
await expect(conn.attach("gone", { onData: () => {}, onExit: () => {} })).rejects.toThrow(
|
||||
"unknown terminal session",
|
||||
);
|
||||
expect(conn.size).toBe(0);
|
||||
expect(client.listenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("lists attachable sessions and tolerates a missing sessions field", async () => {
|
||||
const client = makeFakeClient();
|
||||
const conn = new TerminalConnection(client);
|
||||
const info = {
|
||||
sessionId: "s1",
|
||||
agentId: "main",
|
||||
shell: "/bin/zsh",
|
||||
cwd: "/work",
|
||||
confined: false,
|
||||
attached: false,
|
||||
createdAtMs: 1,
|
||||
};
|
||||
client.nextResponse = { sessions: [info] };
|
||||
expect(await conn.list()).toEqual([info]);
|
||||
client.nextResponse = {};
|
||||
expect(await conn.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("dispose() drops the gateway subscription and clears buffered state", async () => {
|
||||
const client = makeFakeClient();
|
||||
const conn = new TerminalConnection(client);
|
||||
|
||||
@@ -16,6 +16,21 @@ export type TerminalOpenResult = {
|
||||
confined: boolean;
|
||||
};
|
||||
|
||||
export type TerminalAttachResult = TerminalOpenResult & {
|
||||
/** Recent output replayed into the emulator before live data resumes. */
|
||||
buffer: string;
|
||||
};
|
||||
|
||||
export type TerminalSessionInfo = {
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
shell: string;
|
||||
cwd: string;
|
||||
confined: boolean;
|
||||
attached: boolean;
|
||||
createdAtMs: number;
|
||||
};
|
||||
|
||||
export type TerminalExitInfo = {
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
@@ -106,38 +121,73 @@ export class TerminalConnection {
|
||||
params: { agentId?: string; cols: number; rows: number },
|
||||
sink: SessionSink,
|
||||
): Promise<TerminalOpenResult> {
|
||||
const result = await this.requestWhileHoldingStream(() =>
|
||||
this.client.request<TerminalOpenResult>("terminal.open", params),
|
||||
);
|
||||
this.adoptSession(result.sessionId, sink);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebinds an existing (usually detached) session to this connection and
|
||||
* replays its buffered output into the sink before live data resumes.
|
||||
*/
|
||||
async attach(sessionId: string, sink: SessionSink): Promise<TerminalAttachResult> {
|
||||
const result = await this.requestWhileHoldingStream(() =>
|
||||
this.client.request<TerminalAttachResult>("terminal.attach", { sessionId }),
|
||||
);
|
||||
this.adoptSession(sessionId, sink, result.buffer);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Sessions this operator could attach; empty when the surface is off. */
|
||||
async list(): Promise<TerminalSessionInfo[]> {
|
||||
const result = await this.client.request<{ sessions?: TerminalSessionInfo[] }>("terminal.list");
|
||||
return result?.sessions ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the event subscription while an open/attach RPC is in flight so a
|
||||
* concurrent close/exit on another session cannot drop the listener and lose
|
||||
* this session's early output.
|
||||
*/
|
||||
private async requestWhileHoldingStream<T>(run: () => Promise<T>): Promise<T> {
|
||||
this.ensureSubscribed();
|
||||
// Holds the subscription while the RPC is in flight so a concurrent
|
||||
// close/exit on another session cannot drop the listener and lose this
|
||||
// session's early output.
|
||||
this.pendingOpenCount += 1;
|
||||
let result: TerminalOpenResult;
|
||||
try {
|
||||
result = await this.client.request<TerminalOpenResult>("terminal.open", params);
|
||||
const result = await run();
|
||||
this.pendingOpenCount -= 1;
|
||||
return result;
|
||||
} catch (err) {
|
||||
// A rejected open (sandboxed agent, disabled terminal, missing PTY,
|
||||
// disconnect race) never registers a sink. Drop the listener when no
|
||||
// sessions remain so repeated failed opens across reconnects don't
|
||||
// A rejected open/attach (sandboxed agent, disabled terminal, expired
|
||||
// session, disconnect race) never registers a sink. Drop the listener
|
||||
// when no sessions remain so repeated failures across reconnects don't
|
||||
// accumulate listeners on the shared gateway client.
|
||||
this.pendingOpenCount -= 1;
|
||||
this.maybeUnsubscribe();
|
||||
throw err;
|
||||
}
|
||||
this.pendingOpenCount -= 1;
|
||||
this.sinks.set(result.sessionId, sink);
|
||||
}
|
||||
|
||||
/** Registers a sink, replaying the attach buffer first, then early events. */
|
||||
private adoptSession(sessionId: string, sink: SessionSink, replay?: string): void {
|
||||
this.sinks.set(sessionId, sink);
|
||||
if (replay) {
|
||||
sink.onData(replay);
|
||||
}
|
||||
// Replay any events that raced ahead of registration, in arrival order.
|
||||
const early = this.pending.get(result.sessionId);
|
||||
// These are post-snapshot bytes, so they follow the attach replay.
|
||||
const early = this.pending.get(sessionId);
|
||||
if (early) {
|
||||
this.pending.delete(result.sessionId);
|
||||
this.pending.delete(sessionId);
|
||||
for (const event of early) {
|
||||
if (event.kind === "data") {
|
||||
sink.onData(event.data);
|
||||
} else {
|
||||
this.deliverExit(result.sessionId, sink, event.info);
|
||||
this.deliverExit(sessionId, sink, event.info);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,6 +55,12 @@ function shellBasename(shell: string): string {
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = "openclaw.terminal.panel.v1";
|
||||
// Session ids for reattach after a reload/reconnect. Deliberately
|
||||
// sessionStorage, not localStorage: attach is take-over, and a shared
|
||||
// per-origin key would make multiple Control UI windows clobber each other's
|
||||
// ids and steal each other's live shells. Per-tab storage survives exactly the
|
||||
// cases reattach is for (reload, laptop sleep, transient disconnect).
|
||||
const SESSIONS_KEY = "openclaw.terminal.sessions.v1";
|
||||
const DEFAULT_LAYOUT: PanelLayout = { open: false, dock: "bottom", height: 320, width: 520 };
|
||||
const MIN_HEIGHT = 140;
|
||||
const MIN_WIDTH = 320;
|
||||
@@ -95,6 +101,21 @@ function clampSize(value: unknown, min: number, max: number, fallback: number):
|
||||
return Math.min(size, max);
|
||||
}
|
||||
|
||||
function loadPersistedSessionIds(): string[] {
|
||||
try {
|
||||
const raw = globalThis.sessionStorage?.getItem(SESSIONS_KEY);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** `<openclaw-terminal-panel>` — the dockable Control UI shell surface. */
|
||||
export class OpenClawTerminalPanel extends LitElement {
|
||||
/** Gateway client used for terminal.* RPCs; null until connected. */
|
||||
@@ -145,7 +166,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
window.addEventListener(TOGGLE_EVENT, this.onToggleRequest);
|
||||
window.addEventListener("resize", this.onViewportResize);
|
||||
if (this.open) {
|
||||
void this.ensureInitialSession();
|
||||
void this.restoreSessions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,19 +184,20 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
override updated(changed: Map<string, unknown>): void {
|
||||
if (changed.has("available")) {
|
||||
if (!this.available) {
|
||||
// The surface disappeared (gateway disconnect/disable). The server kills
|
||||
// every PTY on disconnect, so tear down local tabs and the connection
|
||||
// (disposeAllTabs drops the gateway subscription too) — otherwise a
|
||||
// reopen after reconnect would show a dead session and skip creating a
|
||||
// fresh one.
|
||||
// The surface disappeared (gateway disconnect/disable). Tear down local
|
||||
// tabs and the connection (disposeAllTabs drops the gateway
|
||||
// subscription too). Server sessions survive a disconnect for the
|
||||
// detach grace period, and their ids stay persisted, so the restore on
|
||||
// reconnect reattaches them instead of opening fresh shells.
|
||||
if (this.open) {
|
||||
this.closePanel();
|
||||
}
|
||||
this.disposeAllTabs();
|
||||
} else if (!this.open && loadLayout().open) {
|
||||
// Hello arrived after mount; restore the persisted open state.
|
||||
// Hello arrived after mount (or a reconnect); restore the persisted
|
||||
// open state and reattach persisted sessions where possible.
|
||||
this.open = true;
|
||||
void this.ensureInitialSession();
|
||||
void this.restoreSessions();
|
||||
}
|
||||
}
|
||||
if (changed.has("themeMode")) {
|
||||
@@ -234,7 +256,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
} else {
|
||||
this.open = true;
|
||||
this.persistLayout();
|
||||
void this.ensureInitialSession();
|
||||
void this.restoreSessions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,12 +273,147 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point whenever the panel (re)opens: reattach persisted sessions if
|
||||
* the gateway still has them, otherwise fall back to one fresh session.
|
||||
*/
|
||||
private async restoreSessions(): Promise<void> {
|
||||
if (!this.client || !this.available || this.booting || this.tabs.length > 0) {
|
||||
await this.ensureInitialSession();
|
||||
return;
|
||||
}
|
||||
const persisted = loadPersistedSessionIds();
|
||||
if (persisted.length > 0) {
|
||||
this.booting = true;
|
||||
try {
|
||||
if (!this.connection) {
|
||||
this.connection = new TerminalConnection(this.client);
|
||||
}
|
||||
const listed = await this.connection.list();
|
||||
const known = new Set(listed.map((session) => session.sessionId));
|
||||
for (const sessionId of persisted.filter((id) => known.has(id))) {
|
||||
await this.attachSession(sessionId);
|
||||
}
|
||||
} catch {
|
||||
// terminal.list failed (older gateway, surface flapping): fall through
|
||||
// to a fresh session below.
|
||||
} finally {
|
||||
this.booting = false;
|
||||
}
|
||||
// Prune ids the gateway no longer knows (reaped or externally closed).
|
||||
this.persistLiveSessions();
|
||||
}
|
||||
await this.ensureInitialSession();
|
||||
}
|
||||
|
||||
private async ensureInitialSession(): Promise<void> {
|
||||
if (this.tabs.length === 0 && !this.booting) {
|
||||
await this.openSession();
|
||||
}
|
||||
}
|
||||
|
||||
/** Boots a tab with a ghostty terminal, ready for an open or attach RPC. */
|
||||
private async bootTab(): Promise<{
|
||||
tab: TerminalTabState;
|
||||
connection: TerminalConnection;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}> {
|
||||
if (!this.client) {
|
||||
throw new Error("terminal client unavailable");
|
||||
}
|
||||
const { init, Terminal, FitAddon } = await import("ghostty-web");
|
||||
await init();
|
||||
if (!this.connection) {
|
||||
this.connection = new TerminalConnection(this.client);
|
||||
}
|
||||
// Captured so the cancelled-open cleanup can close the session even if a
|
||||
// teardown swaps this.connection while the open/attach RPC is in flight.
|
||||
const connection = this.connection;
|
||||
const host = document.createElement("div");
|
||||
host.className = "tp-host";
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
cursorBlink: true,
|
||||
theme: terminalTheme(this.themeMode),
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
const id = `tab-${++this.tabSeq}`;
|
||||
const tab: TerminalTabState = {
|
||||
id,
|
||||
gatewaySessionId: "",
|
||||
shellName: t("terminal.tabLabel", { n: String(this.tabSeq) }),
|
||||
hint: "",
|
||||
term,
|
||||
fit,
|
||||
host,
|
||||
status: "live",
|
||||
};
|
||||
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.activeId = id;
|
||||
// Wait for the panel (and its .tp-viewport) to render before attaching the
|
||||
// ghostty host, so the terminal opens into a laid-out, measurable node.
|
||||
await this.updateComplete;
|
||||
const viewport = this.renderRoot.querySelector(".tp-viewport");
|
||||
if (!viewport) {
|
||||
throw new Error("terminal viewport unavailable");
|
||||
}
|
||||
viewport.append(host);
|
||||
|
||||
term.open(host);
|
||||
fit.fit();
|
||||
return { tab, connection, cols: term.cols || 80, rows: term.rows || 24 };
|
||||
}
|
||||
|
||||
/** Output/exit sink for one tab, shared by open and attach. */
|
||||
private tabSink(tab: TerminalTabState) {
|
||||
return {
|
||||
// The cancelled guard also protects the buffered-event replay inside
|
||||
// connection.open/attach from writing to an already-disposed terminal.
|
||||
onData: (data: string) => {
|
||||
if (!tab.cancelled) {
|
||||
tab.term.write(data);
|
||||
}
|
||||
},
|
||||
onExit: (info: { reason?: string; exitCode: number | null }) => this.handleExit(tab.id, info),
|
||||
};
|
||||
}
|
||||
|
||||
/** Binds a freshly opened or attached gateway session to its tab. */
|
||||
private adoptSession(
|
||||
tab: TerminalTabState,
|
||||
result: { sessionId: string; shell: string; agentId: string; cwd: string },
|
||||
): void {
|
||||
tab.gatewaySessionId = result.sessionId;
|
||||
tab.shellName = shellBasename(result.shell);
|
||||
tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd });
|
||||
|
||||
// Forward keystrokes and viewport resizes to the PTY.
|
||||
tab.term.onData((data) => void this.connection?.input(result.sessionId, data));
|
||||
tab.term.onResize(
|
||||
({ cols: c, rows: r }) => void this.connection?.resize(result.sessionId, c, r),
|
||||
);
|
||||
tab.fit.observeResize();
|
||||
|
||||
this.tabs = [...this.tabs];
|
||||
this.persistLiveSessions();
|
||||
}
|
||||
|
||||
/** Removes a tab whose open/attach never produced a server session. */
|
||||
private dropFailedTab(tab: TerminalTabState): void {
|
||||
this.disposeTab(tab);
|
||||
this.tabs = this.tabs.filter((entry) => entry.id !== tab.id);
|
||||
if (this.activeId === tab.id) {
|
||||
this.activeId = this.tabs.at(-1)?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
private async openSession(): Promise<void> {
|
||||
if (!this.client || !this.available || this.booting) {
|
||||
return;
|
||||
@@ -268,118 +425,80 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
// Tracked outside the try so the catch can dispose a tab whose open failed.
|
||||
let createdTab: TerminalTabState | undefined;
|
||||
try {
|
||||
const { init, Terminal, FitAddon } = await import("ghostty-web");
|
||||
await init();
|
||||
if (!this.connection) {
|
||||
this.connection = new TerminalConnection(this.client);
|
||||
}
|
||||
// Captured so the cancelled-open cleanup below can close the session even
|
||||
// if a teardown swaps this.connection while the open RPC is in flight.
|
||||
const connection = this.connection;
|
||||
const host = document.createElement("div");
|
||||
host.className = "tp-host";
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
cursorBlink: true,
|
||||
theme: terminalTheme(this.themeMode),
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
const id = `tab-${++this.tabSeq}`;
|
||||
const tab: TerminalTabState = {
|
||||
id,
|
||||
gatewaySessionId: "",
|
||||
shellName: t("terminal.tabLabel", { n: String(this.tabSeq) }),
|
||||
hint: "",
|
||||
term,
|
||||
fit,
|
||||
host,
|
||||
status: "live",
|
||||
};
|
||||
createdTab = tab;
|
||||
|
||||
this.tabs = [...this.tabs, tab];
|
||||
this.activeId = id;
|
||||
// Wait for the panel (and its .tp-viewport) to render before attaching the
|
||||
// ghostty host, so the terminal opens into a laid-out, measurable node.
|
||||
await this.updateComplete;
|
||||
const viewport = this.renderRoot.querySelector(".tp-viewport");
|
||||
if (!viewport) {
|
||||
throw new Error("terminal viewport unavailable");
|
||||
}
|
||||
viewport.append(host);
|
||||
|
||||
term.open(host);
|
||||
fit.fit();
|
||||
const cols = term.cols || 80;
|
||||
const rows = term.rows || 24;
|
||||
|
||||
const result = await connection.open(
|
||||
{ agentId, cols, rows },
|
||||
{
|
||||
// The cancelled guard also protects the buffered-event replay inside
|
||||
// connection.open from writing to an already-disposed terminal.
|
||||
onData: (data) => {
|
||||
if (!tab.cancelled) {
|
||||
term.write(data);
|
||||
}
|
||||
},
|
||||
onExit: (info) => this.handleExit(id, info),
|
||||
},
|
||||
const boot = await this.bootTab();
|
||||
createdTab = boot.tab;
|
||||
const result = await boot.connection.open(
|
||||
{ agentId, cols: boot.cols, rows: boot.rows },
|
||||
this.tabSink(boot.tab),
|
||||
);
|
||||
if (tab.cancelled) {
|
||||
if (boot.tab.cancelled) {
|
||||
// The tab's close button was clicked while the open RPC was in flight.
|
||||
// The server session is live and its sink registered; close it now or
|
||||
// it survives invisibly (eating the session cap) until disconnect.
|
||||
void connection.close(result.sessionId);
|
||||
void boot.connection.close(result.sessionId);
|
||||
return;
|
||||
}
|
||||
tab.gatewaySessionId = result.sessionId;
|
||||
tab.shellName = shellBasename(result.shell);
|
||||
tab.hint = t("terminal.tabHint", { agent: result.agentId, cwd: result.cwd });
|
||||
|
||||
// Forward keystrokes and viewport resizes to the PTY.
|
||||
term.onData((data) => void this.connection?.input(result.sessionId, data));
|
||||
term.onResize(({ cols: c, rows: r }) => void this.connection?.resize(result.sessionId, c, r));
|
||||
fit.observeResize();
|
||||
|
||||
this.tabs = [...this.tabs];
|
||||
term.focus();
|
||||
this.adoptSession(boot.tab, result);
|
||||
boot.tab.term.focus();
|
||||
} catch (err) {
|
||||
this.errorText = err instanceof Error ? err.message : String(err);
|
||||
// A failed open (e.g. terminal disabled or a sandboxed agent is refused)
|
||||
// must not leave a phantom "live" tab with no server session. Drop it but
|
||||
// keep the panel open so the error stays visible.
|
||||
if (createdTab && !createdTab.gatewaySessionId) {
|
||||
const dead = createdTab;
|
||||
this.disposeTab(dead);
|
||||
this.tabs = this.tabs.filter((entry) => entry.id !== dead.id);
|
||||
if (this.activeId === dead.id) {
|
||||
this.activeId = this.tabs.at(-1)?.id ?? null;
|
||||
}
|
||||
this.dropFailedTab(createdTab);
|
||||
}
|
||||
} finally {
|
||||
this.booting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reattaches one persisted session; returns false when it is gone. */
|
||||
private async attachSession(sessionId: string): Promise<boolean> {
|
||||
let createdTab: TerminalTabState | undefined;
|
||||
try {
|
||||
const boot = await this.bootTab();
|
||||
createdTab = boot.tab;
|
||||
const result = await boot.connection.attach(sessionId, this.tabSink(boot.tab));
|
||||
if (boot.tab.cancelled) {
|
||||
void boot.connection.close(result.sessionId);
|
||||
return false;
|
||||
}
|
||||
this.adoptSession(boot.tab, result);
|
||||
// The PTY grid rarely matches the new viewport; resizing also delivers
|
||||
// the SIGWINCH repaint that restores full-screen apps after the replay.
|
||||
void boot.connection.resize(result.sessionId, boot.cols, boot.rows);
|
||||
return true;
|
||||
} catch {
|
||||
// Session expired between list and attach (reaper race) or an older
|
||||
// gateway: quietly drop the placeholder tab; restore falls back to a
|
||||
// fresh session when nothing could be reattached.
|
||||
if (createdTab && !createdTab.gatewaySessionId) {
|
||||
this.dropFailedTab(createdTab);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleExit(tabId: string, info: { reason?: string; exitCode: number | null }): void {
|
||||
const tab = this.tabs.find((entry) => entry.id === tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
tab.status = "exited";
|
||||
tab.statusLabel =
|
||||
info.reason === "process_exit" && info.exitCode !== null
|
||||
? t("terminal.exitedCode", { code: String(info.exitCode) })
|
||||
: t("terminal.exited");
|
||||
if (info.reason === "detached") {
|
||||
// Another connection attached this session away; it is alive elsewhere.
|
||||
tab.statusLabel = t("terminal.detached");
|
||||
} else {
|
||||
tab.statusLabel =
|
||||
info.reason === "process_exit" && info.exitCode !== null
|
||||
? t("terminal.exitedCode", { code: String(info.exitCode) })
|
||||
: t("terminal.exited");
|
||||
}
|
||||
// The connection drops its own sink on exit delivery, so no release() here —
|
||||
// the session id may not be recorded yet when an early exit is replayed.
|
||||
this.tabs = [...this.tabs];
|
||||
this.persistLiveSessions();
|
||||
}
|
||||
|
||||
private closeTab(tabId: string): void {
|
||||
@@ -399,6 +518,7 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
if (this.activeId === tabId) {
|
||||
this.activeId = this.tabs.at(-1)?.id ?? null;
|
||||
}
|
||||
this.persistLiveSessions();
|
||||
if (this.tabs.length === 0) {
|
||||
this.closePanel();
|
||||
}
|
||||
@@ -452,6 +572,22 @@ export class OpenClawTerminalPanel extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records which gateway sessions this window's live tabs own so a reload or
|
||||
* reconnect can reattach them. Intentionally NOT cleared on disconnect
|
||||
* teardown (disposeAllTabs) — surviving ids are the reattach memory.
|
||||
*/
|
||||
private persistLiveSessions(): void {
|
||||
const ids = this.tabs
|
||||
.filter((tab) => tab.status === "live" && tab.gatewaySessionId)
|
||||
.map((tab) => tab.gatewaySessionId);
|
||||
try {
|
||||
globalThis.sessionStorage?.setItem(SESSIONS_KEY, JSON.stringify(ids));
|
||||
} catch {
|
||||
// Storage may be unavailable (private mode); reattach just won't work.
|
||||
}
|
||||
}
|
||||
|
||||
private persistLayout(): void {
|
||||
try {
|
||||
const layout: PanelLayout = {
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:34:36.821Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:56.254Z",
|
||||
"locale": "ar",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:33:24.509Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:54.271Z",
|
||||
"locale": "de",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:34:04.861Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:54.790Z",
|
||||
"locale": "es",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:36:18.937Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:58.431Z",
|
||||
"locale": "fa",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:34:06.604Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:55.726Z",
|
||||
"locale": "fr",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:20:57.063Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:55.994Z",
|
||||
"locale": "hi",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:35:14.841Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:57.137Z",
|
||||
"locale": "id",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:34:48.573Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:56.509Z",
|
||||
"locale": "it",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:33:59.819Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:55.171Z",
|
||||
"locale": "ja-JP",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:34:04.704Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:55.430Z",
|
||||
"locale": "ko",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:35:55.199Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:58.209Z",
|
||||
"locale": "nl",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:35:33.847Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:57.414Z",
|
||||
"locale": "pl",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:33:19.571Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:53.883Z",
|
||||
"locale": "pt-BR",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:20:59.216Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:58.663Z",
|
||||
"locale": "ru",
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:35:34.946Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:57.670Z",
|
||||
"locale": "th",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:34:51.187Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:56.713Z",
|
||||
"locale": "tr",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:34:47.887Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:56.925Z",
|
||||
"locale": "uk",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:35:30.759Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:57.986Z",
|
||||
"locale": "vi",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:33:19.432Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:52.333Z",
|
||||
"locale": "zh-CN",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+6
-4
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"fallbackKeys": [],
|
||||
"generatedAt": "2026-07-04T18:33:24.594Z",
|
||||
"fallbackKeys": [
|
||||
"terminal.detached"
|
||||
],
|
||||
"generatedAt": "2026-07-04T19:40:53.518Z",
|
||||
"locale": "zh-TW",
|
||||
"model": "claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"sourceHash": "f2d6998c09b3009d63c98ca8b0187b55c47e60df8b8b33bc1f4dab2ba65130cc",
|
||||
"totalKeys": 1462,
|
||||
"sourceHash": "1437172eaecf40feed27c9801bbff868ddc17fbc0769cf1a8d9208f8b3341013",
|
||||
"totalKeys": 1463,
|
||||
"translatedKeys": 1462,
|
||||
"workflow": 1
|
||||
}
|
||||
|
||||
Generated
+1
@@ -439,6 +439,7 @@ export const ar: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -443,6 +443,7 @@ export const de: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
@@ -438,6 +438,7 @@ export const en: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -441,6 +441,7 @@ export const es: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -441,6 +441,7 @@ export const fa: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -443,6 +443,7 @@ export const fr: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -440,6 +440,7 @@ export const hi: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -440,6 +440,7 @@ export const id: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -444,6 +444,7 @@ export const it: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -444,6 +444,7 @@ export const ja_JP: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -439,6 +439,7 @@ export const ko: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -442,6 +442,7 @@ export const nl: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -441,6 +441,7 @@ export const pl: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -440,6 +440,7 @@ export const pt_BR: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -443,6 +443,7 @@ export const ru: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -438,6 +438,7 @@ export const th: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -442,6 +442,7 @@ export const tr: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -441,6 +441,7 @@ export const uk: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -440,6 +440,7 @@ export const vi: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -437,6 +437,7 @@ export const zh_CN: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Generated
+1
@@ -437,6 +437,7 @@ export const zh_TW: TranslationMap = {
|
||||
tabHint: "{agent} · {cwd}",
|
||||
exited: "exited",
|
||||
exitedCode: "exited ({code})",
|
||||
detached: "detached",
|
||||
dockBottom: "Dock to bottom",
|
||||
dockRight: "Dock to right",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user