From 1ba3f086d6ff2ea55b1c24c751c0cab5e3a51d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 11 Jun 2026 22:27:06 +0100 Subject: [PATCH] fix(web): update session tab title on model changes (#1335) --- .agents/agents/pr-poller.md | 4 +- .../task/repository/sqlite/session.go | 3 +- apps/web/components/model-config-selector.tsx | 5 +- .../components/task/session-tab-title.test.ts | 91 +++++++++++++++++++ apps/web/components/task/session-tab-title.ts | 48 ++++++++++ apps/web/components/task/session-tab.tsx | 48 +++++++--- apps/web/e2e/README.md | 2 +- scripts/pr-state | 2 +- scripts/pr-state.test.sh | 21 +++++ 9 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 apps/web/components/task/session-tab-title.test.ts create mode 100644 apps/web/components/task/session-tab-title.ts diff --git a/.agents/agents/pr-poller.md b/.agents/agents/pr-poller.md index 13cf67892e..d4ca6a4ab4 100644 --- a/.agents/agents/pr-poller.md +++ b/.agents/agents/pr-poller.md @@ -125,12 +125,12 @@ The `claude_summary` line carries the **latest** Claude summary's structured fin }' -f owner=":owner" -f repo=":repo" -F 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 --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 | |` / `| Suggestion | |` 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 | |` / `| Suggestion | |` and a `**Verdict:** ...` line. Read only the **latest** such comment — earlier summaries reflect previous commit states. ```bash body=$(gh api repos/:owner/:repo/issues//comments --jq ' diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 2e4b9705ab..0f7a24d041 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -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 = ? diff --git a/apps/web/components/model-config-selector.tsx b/apps/web/components/model-config-selector.tsx index 32a28ad578..950122f670 100644 --- a/apps/web/components/model-config-selector.tsx +++ b/apps/web/components/model-config-selector.tsx @@ -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; } diff --git a/apps/web/components/task/session-tab-title.test.ts b/apps/web/components/task/session-tab-title.test.ts new file mode 100644 index 0000000000..bd597eb57d --- /dev/null +++ b/apps/web/components/task/session-tab-title.test.ts @@ -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)"); + }); +}); diff --git a/apps/web/components/task/session-tab-title.ts b/apps/web/components/task/session-tab-title.ts new file mode 100644 index 0000000000..321d85b758 --- /dev/null +++ b/apps/web/components/task/session-tab-title.ts @@ -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) + ); +} diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 7748529482..af800f2428 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -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 diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index f1cff8f7b4..d781277a15 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -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. > diff --git a/scripts/pr-state b/scripts/pr-state index daee94f6f0..54b51e5237 100755 --- a/scripts/pr-state +++ b/scripts/pr-state @@ -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 diff --git a/scripts/pr-state.test.sh b/scripts/pr-state.test.sh index 62573cce6b..2fd70fbe13 100644 --- a/scripts/pr-state.test.sh +++ b/scripts/pr-state.test.sh @@ -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