fix(web): update session tab title on model changes (#1335)

This commit is contained in:
Carlos Florêncio
2026-06-11 22:27:06 +01:00
committed by GitHub
parent 326297c3e7
commit 1ba3f086d6
9 changed files with 206 additions and 18 deletions
+2 -2
View File
@@ -125,12 +125,12 @@ The `claude_summary` line carries the **latest** Claude summary's structured fin
}' -f owner=":owner" -f repo=":repo" -F num=<num> --jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false)] | length'
```
5. **Fallback only: count bot issue comments** (CodeRabbit walkthrough, fork-Claude findings, etc.). Skip this when `scripts/pr-state` already returned `.issue_comments` without an `issue_comments` error:
5. **Fallback only: count bot issue comments** (CodeRabbit walkthrough, fork-Claude findings, etc.). Skip this when `scripts/pr-state` already returned `.issue_comments` without a `pr_view` error:
```bash
gh pr view <num> --json comments --jq '[.comments[] | select(.author.login | IN("coderabbitai","github-actions"))] | length'
```
6. **Fallback only: parse the latest Claude summary** for structured findings. Skip this when `scripts/pr-state` already returned `.issue_comments` without an `issue_comments` error. Claude posts its review summary as an *issue comment* (not a review) — either as `claude[bot]` (same-repo app) or as `github-actions` with a body that begins `**Claude finished ` (fork path). Each summary ends with a markdown table of the form `| Blocker | <N> |` / `| Suggestion | <N> |` and a `**Verdict:** ...` line. Read only the **latest** such comment — earlier summaries reflect previous commit states.
6. **Fallback only: parse the latest Claude summary** for structured findings. Skip this when `scripts/pr-state` already returned `.issue_comments` without a `pr_view` error. Claude posts its review summary as an *issue comment* (not a review) — either as `claude[bot]` (same-repo app) or as `github-actions` with a body that begins `**Claude finished ` (fork path). Each summary ends with a markdown table of the form `| Blocker | <N> |` / `| Suggestion | <N> |` and a `**Verdict:** ...` line. Read only the **latest** such comment — earlier summaries reflect previous commit states.
```bash
body=$(gh api repos/:owner/:repo/issues/<num>/comments --jq '
@@ -521,7 +521,8 @@ func (r *Repository) UpdateTaskSessionState(ctx context.Context, id string, stat
// task's sessions independent of agent-process teardown.
func (r *Repository) CancelActiveTaskSessionsByTaskID(ctx context.Context, taskID, reason string) (int64, error) {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
writeCtx := context.WithoutCancel(ctx)
result, err := r.db.ExecContext(writeCtx, r.db.Rebind(`
UPDATE task_sessions
SET state = ?, error_message = ?, completed_at = ?, updated_at = ?
WHERE task_id = ?
@@ -77,7 +77,10 @@ function currentOptionName(option: DynamicConfigOption): string {
);
}
function displayModelName(modelOptions: ModelSelectorOption[], currentModel: string): string {
export function displayModelName(
modelOptions: ModelSelectorOption[],
currentModel: string,
): string {
return modelOptions.find((m) => m.id === currentModel)?.name ?? currentModel;
}
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { resolveSessionTabTitle } from "./session-tab-title";
const SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const SPARK_MODEL_NAME = "GPT-5.3-Codex-Spark";
const baseArgs = {
agentLabel: "GPT-5.5 (medium)",
activeModelId: null,
currentModelId: null,
snapshotModel: null,
modelOptions: [],
configOptions: [],
};
describe("resolveSessionTabTitle", () => {
it("uses the active model label over the original agent label", () => {
expect(
resolveSessionTabTitle({
...baseArgs,
activeModelId: SPARK_MODEL_ID,
modelOptions: [{ id: SPARK_MODEL_ID, name: SPARK_MODEL_NAME }],
}),
).toBe(SPARK_MODEL_NAME);
});
it("includes non-model config selections in the title", () => {
expect(
resolveSessionTabTitle({
...baseArgs,
activeModelId: SPARK_MODEL_ID,
configOptions: [
{
type: "select",
id: "model",
name: "Model",
currentValue: "gpt-5.5",
options: [
{ value: "gpt-5.5", name: "GPT-5.5" },
{ value: SPARK_MODEL_ID, name: SPARK_MODEL_NAME },
],
},
{
type: "select",
id: "effort",
name: "Effort",
currentValue: "high",
options: [
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
],
},
],
}),
).toBe(`${SPARK_MODEL_NAME} / High`);
});
it("falls back to the agent label when live model state is unavailable", () => {
expect(resolveSessionTabTitle(baseArgs)).toBe("GPT-5.5 (medium)");
});
it("falls back to currentModelId when active model id is missing", () => {
expect(
resolveSessionTabTitle({
...baseArgs,
currentModelId: SPARK_MODEL_ID,
modelOptions: [{ id: SPARK_MODEL_ID, name: SPARK_MODEL_NAME }],
}),
).toBe(SPARK_MODEL_NAME);
});
it("falls back to snapshot model when both agent and live model states are unavailable", () => {
expect(
resolveSessionTabTitle({
...baseArgs,
agentLabel: null,
snapshotModel: SPARK_MODEL_ID,
modelOptions: [{ id: SPARK_MODEL_ID, name: SPARK_MODEL_NAME }],
}),
).toBe(SPARK_MODEL_NAME);
});
it("keeps the agent label over the start-time snapshot model", () => {
expect(
resolveSessionTabTitle({
...baseArgs,
snapshotModel: "gpt-5.5",
}),
).toBe("GPT-5.5 (medium)");
});
});
@@ -0,0 +1,48 @@
import {
displayModelName,
isModelConfigOption,
type DynamicConfigOption,
type ModelSelectorOption,
} from "@/components/model-config-selector";
type ResolveSessionTabTitleArgs = {
agentLabel: string | null;
activeModelId: string | null;
currentModelId: string | null;
snapshotModel: string | null;
modelOptions: ModelSelectorOption[];
configOptions: DynamicConfigOption[];
};
function optionName(option: DynamicConfigOption, value: string): string {
return option.options?.find((item) => item.value === value)?.name ?? value;
}
function resolveModelTitle(
args: ResolveSessionTabTitleArgs,
modelId: string | null,
): string | null {
if (!modelId) return null;
const modelConfig = args.configOptions.find(isModelConfigOption);
let modelLabel = displayModelName(args.modelOptions, modelId);
if (modelConfig) {
// Use caller-supplied modelId, not modelConfig.currentValue, so live
// active/current model switches are reflected immediately in the tab title.
modelLabel = optionName(modelConfig, modelId);
}
const extras = args.configOptions
.filter((option) => !isModelConfigOption(option))
.map((option) => optionName(option, option.currentValue))
.filter(Boolean);
return [modelLabel, ...extras].join(" / ");
}
export function resolveSessionTabTitle(args: ResolveSessionTabTitleArgs): string | null {
const liveModelId = args.activeModelId || args.currentModelId;
return (
resolveModelTitle(args, liveModelId) ??
args.agentLabel ??
resolveModelTitle(args, args.snapshotModel)
);
}
+36 -12
View File
@@ -33,8 +33,10 @@ import { shareableSessionStateClient } from "@/components/task/share/share-butto
import { ShareDialog } from "@/components/task/share/share-dialog";
import { HandoffContextMenuSub } from "@/components/task/handoff-profile-menu-items";
import { NewSessionDialog, type HandoffPreset } from "@/components/task/new-session-dialog";
import { usableConfigOptions } from "@/components/model-config-selector";
import type { TaskSessionState } from "@/lib/types/http";
import { isSessionActive } from "./session-sort";
import { resolveSessionTabTitle } from "./session-tab-title";
import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize";
function useSessionTabState(sessionId: string | undefined) {
@@ -50,16 +52,38 @@ function useSessionTabState(sessionId: string | undefined) {
return state.taskSessions.items[sessionId]?.state ?? null;
}) as TaskSessionState | null;
const taskId = useAppStore((state) => state.tasks.activeTaskId);
const agentLabel = useAppStore((state) => {
const tabTitle = useAppStore((state) => {
if (!sessionId) return null;
const session = state.taskSessions.items[sessionId];
if (!session?.agent_profile_id) return null;
const profile = state.agentProfiles.items.find(
(p: { id: string }) => p.id === session.agent_profile_id,
);
if (!profile) return null;
const parts = profile.label.split(" \u2022 ");
return parts[1] || parts[0] || profile.label;
const sessionModels = state.sessionModels.bySessionId[sessionId];
const activeModelId = state.activeModel.bySessionId[sessionId] || null;
const agentLabel = (() => {
if (!session?.agent_profile_id) return null;
const profile = state.agentProfiles.items.find(
(p: { id: string }) => p.id === session.agent_profile_id,
);
if (!profile) return null;
const parts = profile.label.split(" \u2022 ");
return parts[1] || parts[0] || profile.label;
})();
const snapshotModel =
typeof session?.agent_profile_snapshot?.model === "string"
? session.agent_profile_snapshot.model
: null;
return resolveSessionTabTitle({
agentLabel,
activeModelId,
currentModelId: sessionModels?.currentModelId || null,
snapshotModel,
modelOptions:
sessionModels?.models.map((model) => ({
id: model.modelId,
name: model.name,
description: model.description,
usageMultiplier: model.usageMultiplier,
})) ?? [],
configOptions: usableConfigOptions(sessionModels?.configOptions),
});
});
const agentName = useAppStore((state) => {
if (!sessionId) return null;
@@ -89,7 +113,7 @@ function useSessionTabState(sessionId: string | undefined) {
if (!activeTaskId) return 0;
return state.taskSessionsByTask.itemsByTaskId[activeTaskId]?.length ?? 0;
});
return { isPrimary, sessionState, taskId, agentLabel, agentName, sessionNumber, sessionCount };
return { isPrimary, sessionState, taskId, tabTitle, agentName, sessionNumber, sessionCount };
}
function useSessionTabActions(
@@ -303,7 +327,7 @@ function SessionTabTriggerContent({
export function SessionTab(props: IDockviewPanelHeaderProps) {
const { api, containerApi } = props;
const sessionId = api.id.startsWith("session:") ? api.id.slice("session:".length) : undefined;
const { isPrimary, sessionState, taskId, agentLabel, agentName, sessionNumber, sessionCount } =
const { isPrimary, sessionState, taskId, tabTitle, agentName, sessionNumber, sessionCount } =
useSessionTabState(sessionId);
const actions = useSessionTabActions(sessionId, taskId, api, containerApi);
const onDoubleClick = useTabMaximizeOnDoubleClick(api);
@@ -328,8 +352,8 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
}, [api]);
useEffect(() => {
if (agentLabel && api.title !== agentLabel) api.setTitle(agentLabel);
}, [agentLabel, api]);
if (tabTitle && api.title !== tabTitle) api.setTitle(tabTitle);
}, [tabTitle, api]);
const showMultiSessionBadges = sessionCount > 1;
// Multi-session tab close means delete, not hide-only. Running/starting sessions are
+1 -1
View File
@@ -90,7 +90,7 @@ pnpm e2e:clean # remove build/test artifacts, in
Why a script instead of raw `docker run`: in docker mode it builds the CGO/`fts5` backend on the **host** and runs it in the runtime image — a host glibc that's the same or older than the image's (the usual case; the image tracks recent Ubuntu) is forward-compatible, so no build image is needed. The runner smoke-tests the binary in the image first and only falls back to the build image (`KANDEV_CI_BUILD_IMAGE`, default `…/kandev-ci:build-latest`) if your host glibc is _newer_. It also builds the FE standalone on the host, pre-creates the standalone symlinks as **relative** links (so in-container `global-setup` doesn't recreate them as root), points Playwright output at a container-local dir, and cleans up. Run `clean` if a previous bare `docker run` left root-owned files you can't delete.
> **Apple Silicon:** the docker path runs the amd64 CI image. Under Docker Desktop's default QEMU emulation the amd64 Go toolchain segfaults (`SIGSEGV` in `modindex.dirHash`) during backend build. Use Colima with Rosetta instead: `colima start --vz-rosetta`. QEMU is not viable for the Go build; Rosetta is required for local amd64 E2E repro on arm64.
> **Apple Silicon:** the docker path runs the amd64 CI image. Under Docker Desktop's default QEMU emulation the amd64 Go toolchain segfaults (`SIGSEGV` in `modindex.dirHash`) during backend build. Use Colima with Rosetta instead: `colima start --vm-type=vz --vz-rosetta`. QEMU is not viable for the Go build; Rosetta is required for local amd64 E2E repro on arm64.
> **Office is always enabled in e2e (and dev); only prod gates it off.** `profiles.yaml` sets `KANDEV_FEATURES_OFFICE` to `"true"` in the `e2e` and `dev` profiles and `"false"` in `prod`, and the fixture selects the e2e profile via `KANDEV_E2E_MOCK=true`. So `tests/office/*` always have office routes registered — no manual env setup.
>
+1 -1
View File
@@ -197,7 +197,7 @@ else
if [[ "$PR" =~ ^[0-9]+$ ]]; then
pr_json="$(jq -cn --argjson number "$PR" '{number: $number, branch: "unknown"}')"
else
pr_json="$(jq -cn --arg pr "$PR" '{number: $pr, branch: "unknown"}')"
pr_json="$(jq -cn --arg branch "$PR" '{number: null, branch: $branch}')"
fi
add_error "pr_view" "gh pr view failed"
fi
+21
View File
@@ -54,6 +54,11 @@ if [[ "${GH_FAIL_GRAPHQL:-0}" == "1" && "$1" == "api" && "$2" == "graphql" ]]; t
exit 1
fi
if [[ "${GH_FAIL_PR_VIEW:-0}" == "1" && "$1" == "pr" && "$2" == "view" ]]; then
echo "pr view failed" >&2
exit 1
fi
if [[ "${GH_FAIL_REPO:-0}" == "1" && "$1" == "repo" && "$2" == "view" ]]; then
echo "repo view failed" >&2
exit 1
@@ -296,6 +301,21 @@ test_partial_failure_records_error_but_keeps_other_data() {
pass "partial failure records error but keeps other data"
}
test_pr_view_failure_with_non_numeric_ref_keeps_schema() {
local tmp
make_tmp_dir tmp
make_mock_gh "$tmp/bin"
local json
GH_FAIL_PR_VIEW=1 PATH="$tmp/bin:$PATH" "$SCRIPT" feat/pr-state >"$tmp/out.json"
json="$(<"$tmp/out.json")"
assert_jq "pr number is null on ref-based invocation" '.pr.number == null' "$json"
assert_jq "pr branch preserved" '.pr.branch == "feat/pr-state"' "$json"
assert_jq "pr_view error recorded" '.errors[] | select(.source == "pr_view") | .message == "gh pr view failed"' "$json"
pass "pr_view failure keeps schema stable for non-numeric refs"
}
test_repo_failure_skips_review_threads() {
local tmp
make_tmp_dir tmp
@@ -349,6 +369,7 @@ test_graphql_pagination_collects_all_threads() {
test_snapshot_happy_path
test_partial_failure_records_error_but_keeps_other_data
test_pr_view_failure_with_non_numeric_ref_keeps_schema
test_repo_failure_skips_review_threads
test_graphql_failure_records_error_but_keeps_other_data
test_graphql_pagination_collects_all_threads