Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
99546ca3d9 fix: land sessions label edits (#1294) (thanks @bradleypriest) 2026-01-20 11:05:24 +00:00
Bradley Priest
26a5d02e69 ui(sessions): support editing session labels
Expose session "label" as an editable field in the Sessions view and persist changes via sessions.patch.
2026-01-20 10:57:36 +00:00
6 changed files with 112 additions and 4 deletions

View File

@ -18,6 +18,7 @@ Docs: https://docs.clawd.bot
- Auth: dedupe codex-cli profiles when tokens match custom openai-codex entries. (#1264) — thanks @odrobnik.
- Agents: avoid misclassifying context-window-too-small errors as context overflow. (#1266) — thanks @humanwritten.
- Slack: resolve Bolt default-export shapes for monitor startup. (#1208) — thanks @24601.
- UI: allow editing session labels in the Sessions table. (#1294) — thanks @bradleypriest.
## 2026.1.19-3

View File

@ -10,7 +10,6 @@ import {
signDevicePayload,
} from "../infra/device-identity.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,

View File

@ -111,7 +111,7 @@ export function enqueueCommand<T>(
return enqueueCommandInLane(CommandLane.Main, task, opts);
}
export function getQueueSize(lane = CommandLane.Main) {
export function getQueueSize(lane: string = CommandLane.Main) {
const state = lanes.get(lane);
if (!state) return 0;
return state.queue.length + state.active;
@ -125,7 +125,7 @@ export function getTotalQueueSize() {
return total;
}
export function clearCommandLane(lane = CommandLane.Main) {
export function clearCommandLane(lane: string = CommandLane.Main) {
const cleaned = lane.trim() || CommandLane.Main;
const state = lanes.get(cleaned);
if (!state) return 0;

View File

@ -43,6 +43,7 @@ export async function patchSession(
state: SessionsState,
key: string,
patch: {
label?: string | null;
thinkingLevel?: string | null;
verboseLevel?: string | null;
reasoningLevel?: string | null;
@ -50,6 +51,7 @@ export async function patchSession(
) {
if (!state.client || !state.connected) return;
const params: Record<string, unknown> = { key };
if ("label" in patch) params.label = patch.label;
if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel;
if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel;
if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel;

View File

@ -0,0 +1,93 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { GatewaySessionRow, SessionsListResult } from "../types";
import { renderSessions, type SessionsProps } from "./sessions";
function createRow(overrides: Partial<GatewaySessionRow> = {}): GatewaySessionRow {
return {
key: "session-1",
kind: "direct",
updatedAt: 0,
...overrides,
};
}
function createResult(rows: GatewaySessionRow[]): SessionsListResult {
return {
ts: 0,
path: "/sessions",
count: rows.length,
defaults: {
model: null,
contextTokens: null,
},
sessions: rows,
};
}
function createProps(overrides: Partial<SessionsProps> = {}): SessionsProps {
return {
loading: false,
result: createResult([]),
error: null,
activeMinutes: "",
limit: "",
includeGlobal: true,
includeUnknown: true,
basePath: "/",
onFiltersChange: () => undefined,
onRefresh: () => undefined,
onPatch: () => undefined,
onDelete: () => undefined,
...overrides,
};
}
describe("sessions view", () => {
it("skips patching when the label is unchanged", () => {
const container = document.createElement("div");
const onPatch = vi.fn();
const row = createRow({ label: "Alpha" });
render(
renderSessions(
createProps({
result: createResult([row]),
onPatch,
}),
),
container,
);
const input = container.querySelector("input") as HTMLInputElement | null;
expect(input).not.toBeNull();
input?.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).not.toHaveBeenCalled();
});
it("clears labels when the input is empty", () => {
const container = document.createElement("div");
const onPatch = vi.fn();
const row = createRow({ label: "Alpha" });
render(
renderSessions(
createProps({
result: createResult([row]),
onPatch,
}),
),
container,
);
const input = container.querySelector("input") as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) return;
input.value = " ";
input.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith("session-1", { label: null });
});
});

View File

@ -24,6 +24,7 @@ export type SessionsProps = {
onPatch: (
key: string,
patch: {
label?: string | null;
thinkingLevel?: string | null;
verboseLevel?: string | null;
reasoningLevel?: string | null;
@ -195,7 +196,19 @@ function renderRow(
<div class="mono">${canLink
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div>
<div>${row.label ?? ""}</div>
<div>
<input
.value=${row.label ?? ""}
?disabled=${disabled}
placeholder="(optional)"
@change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim();
const current = (row.label ?? "").trim();
if (value === current) return;
onPatch(row.key, { label: value || null });
}}
/>
</div>
<div>${row.kind}</div>
<div>${updated}</div>
<div>${formatSessionTokens(row)}</div>