fix(web): update session tab title on model changes (#1335)
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user