Compare commits

...
15 changed files with 1872 additions and 68 deletions
+78
View File
@@ -0,0 +1,78 @@
import { spawn } from "node:child_process"
import fs from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
const require = createRequire(import.meta.url)
const { pack } = require("@vscode/vsce/out/package")
const cwd = process.cwd()
function parseArgs(argv) {
let out
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === "--out" || arg === "-o") {
out = argv[i + 1]
i++
}
}
return {
out: out ?? "dist/cline.vsix",
}
}
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: "inherit",
shell: false,
...options,
})
child.on("error", reject)
child.on("exit", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${command} ${args.join(" ")} exited with code ${code}`))
}
})
})
}
async function main() {
const { out } = parseArgs(process.argv.slice(2))
const packagePath = path.resolve(cwd, out)
await fs.mkdir(path.dirname(packagePath), { recursive: true })
console.log("[package:vsix] Generating protobuf outputs...")
await run("npm", ["run", "protos"])
console.log("[package:vsix] Building webview bundle...")
await run("npx", ["vite", "build"], { cwd: path.join(cwd, "webview-ui") })
console.log("[package:vsix] Building extension bundle...")
await run("node", ["esbuild.mjs", "--production"])
console.log("[package:vsix] Packing VSIX...")
const result = await pack({
cwd,
packagePath,
useYarn: false,
dependencies: false,
allowPackageSecrets: ["sendgrid"],
})
console.log(`[package:vsix] VSIX created at ${result.packagePath}`)
console.log(`[package:vsix] Packaged ${result.files.length} files`)
}
main().catch((error) => {
console.error("[package:vsix] Failed:", error)
process.exit(1)
})
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""
Generate a dependency-free SVG visualization of task activity over time.
For each task, plot a vertical whisker where:
- bottom = conservative agent busy time
- middle marker = agent busy time
- top = session span
Tasks are laid out chronologically on the x-axis by retained session start time.
"""
from __future__ import annotations
import argparse
import html
import json
import math
import sys
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
from task_activity_report import analyze_task_directory, human_duration
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Plot task activity whiskers over time as an SVG image")
parser.add_argument("tasks_root", help="Directory containing per-task subdirectories")
parser.add_argument("--threshold-minutes", type=float, default=15.0)
parser.add_argument("--min-session-minutes", type=float, default=2.0)
parser.add_argument("--conservative-gap-cap-seconds", type=float, default=10.0)
parser.add_argument("--timezone", default="America/Los_Angeles")
parser.add_argument(
"--output",
default="task_activity_whiskers.svg",
help="Output SVG path (default: task_activity_whiskers.svg)",
)
parser.add_argument("--width", type=int, default=1800)
parser.add_argument("--height", type=int, default=900)
parser.add_argument("--title", default="Cline Task Activity Over Time")
return parser.parse_args()
def is_task_dir(path: Path) -> bool:
return (path / "ui_messages.json").exists() and (path / "api_conversation_history.json").exists()
def svg_text(x: float, y: float, text: str, size: int = 12, anchor: str = "start", fill: str = "#111827", weight: str = "normal", rotate: float | None = None) -> str:
transform = f' transform="rotate({rotate:.2f} {x:.2f} {y:.2f})"' if rotate is not None else ""
return (
f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" text-anchor="{anchor}" '
f'fill="{fill}" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-weight="{weight}"{transform}>'
f"{html.escape(text)}</text>"
)
def format_hours(ms: int) -> str:
return f"{ms / 3_600_000:.2f}h"
def summarize_prompt(task_analysis: dict[str, Any]) -> str:
for session in task_analysis.get("sessions", []):
if session.get("kept") and session.get("initiating_prompt"):
return session["initiating_prompt"]
return "(no prompt found)"
def create_svg(results: list[dict[str, Any]], width: int, height: int, title: str, tz_name: str) -> str:
margin = {"top": 80, "right": 40, "bottom": 220, "left": 110}
plot_x = margin["left"]
plot_y = margin["top"]
plot_w = width - margin["left"] - margin["right"]
plot_h = height - margin["top"] - margin["bottom"]
max_ms = max((r["totalSessionSpanMs"] for r in results), default=1)
max_ms = max(max_ms, 1)
def y_scale(value_ms: int) -> float:
return plot_y + plot_h - (value_ms / max_ms) * plot_h
def x_scale(index: int) -> float:
if len(results) == 1:
return plot_x + plot_w / 2
return plot_x + (index / (len(results) - 1)) * plot_w
parts: list[str] = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">',
'<rect width="100%" height="100%" fill="#ffffff" />',
svg_text(width / 2, 36, title, size=24, anchor="middle", weight="700"),
svg_text(width / 2, 58, f"Whisker: conservative → session span, marker: agent busy, timezone: {tz_name}", size=12, anchor="middle", fill="#4b5563"),
]
# Grid + y-axis labels
grid_steps = 6
for i in range(grid_steps + 1):
value = int(max_ms * i / grid_steps)
y = y_scale(value)
parts.append(f'<line x1="{plot_x}" y1="{y:.2f}" x2="{plot_x + plot_w}" y2="{y:.2f}" stroke="#e5e7eb" stroke-width="1" />')
parts.append(svg_text(plot_x - 10, y + 4, format_hours(value), size=11, anchor="end", fill="#6b7280"))
# Axes
parts.append(f'<line x1="{plot_x}" y1="{plot_y}" x2="{plot_x}" y2="{plot_y + plot_h}" stroke="#111827" stroke-width="1.5" />')
parts.append(f'<line x1="{plot_x}" y1="{plot_y + plot_h}" x2="{plot_x + plot_w}" y2="{plot_y + plot_h}" stroke="#111827" stroke-width="1.5" />')
parts.append(svg_text(28, plot_y + plot_h / 2, "Task duration", size=13, anchor="middle", fill="#374151", rotate=-90))
parts.append(svg_text(plot_x + plot_w / 2, height - 28, "Tasks ordered by retained session start time", size=13, anchor="middle", fill="#374151"))
# Legend
legend_x = plot_x + 20
legend_y = plot_y + 10
parts.append(f'<line x1="{legend_x}" y1="{legend_y}" x2="{legend_x}" y2="{legend_y + 28}" stroke="#2563eb" stroke-width="2.5" />')
parts.append(f'<circle cx="{legend_x}" cy="{legend_y + 14}" r="4.5" fill="#dc2626" />')
parts.append(svg_text(legend_x + 12, legend_y + 5, "whisker = conservative to session span", size=12, fill="#374151"))
parts.append(svg_text(legend_x + 12, legend_y + 22, "red marker = agent busy", size=12, fill="#374151"))
# Plot points
for idx, result in enumerate(results):
x = x_scale(idx)
y_low = y_scale(result["totalConservativeAgentBusyMs"])
y_mid = y_scale(result["totalAgentBusyMs"])
y_high = y_scale(result["totalSessionSpanMs"])
prompt = summarize_prompt(result)
task_id = Path(result["taskDir"]).name
tooltip = (
f"Task {task_id}\n"
f"Conservative: {result['totalConservativeAgentBusyHuman']}\n"
f"Agent busy: {result['totalAgentBusyHuman']}\n"
f"Session span: {result['totalSessionSpanHuman']}\n"
f"Start: {result.get('retainedSpanStartHuman') or 'n/a'}\n"
f"Prompt: {prompt[:220]}"
)
parts.append(f'<g><title>{html.escape(tooltip)}</title>')
parts.append(f'<line x1="{x:.2f}" y1="{y_low:.2f}" x2="{x:.2f}" y2="{y_high:.2f}" stroke="#2563eb" stroke-width="2.5" />')
parts.append(f'<line x1="{x - 6:.2f}" y1="{y_low:.2f}" x2="{x + 6:.2f}" y2="{y_low:.2f}" stroke="#2563eb" stroke-width="2" />')
parts.append(f'<line x1="{x - 6:.2f}" y1="{y_high:.2f}" x2="{x + 6:.2f}" y2="{y_high:.2f}" stroke="#2563eb" stroke-width="2" />')
parts.append(f'<circle cx="{x:.2f}" cy="{y_mid:.2f}" r="4.5" fill="#dc2626" stroke="#ffffff" stroke-width="1" />')
parts.append('</g>')
label = result.get("retainedSpanStartHuman", "")[:10]
parts.append(svg_text(x, plot_y + plot_h + 20, label, size=10, anchor="end", fill="#6b7280", rotate=-50))
# Top summaries
if results:
parts.append(svg_text(plot_x, height - 88, f"Tasks plotted: {len(results)}", size=12, fill="#374151"))
parts.append(svg_text(plot_x, height - 68, f"Longest conservative horizon: {results[0]['totalConservativeAgentBusyHuman']} ({Path(results[0]['taskDir']).name})", size=12, fill="#374151"))
parts.append('</svg>')
return "\n".join(parts)
def main() -> int:
args = parse_args()
root = Path(args.tasks_root).expanduser().resolve()
if not root.exists() or not root.is_dir():
print(f"Tasks root must be an existing directory: {root}", file=sys.stderr)
return 1
try:
tz = ZoneInfo(args.timezone)
except Exception as error:
print(f"Invalid timezone '{args.timezone}': {error}", file=sys.stderr)
return 1
results: list[dict[str, Any]] = []
for child in sorted(root.iterdir()):
if not child.is_dir() or not is_task_dir(child):
continue
try:
analysis = analyze_task_directory(
task_dir=child,
threshold_minutes=args.threshold_minutes,
min_session_minutes=args.min_session_minutes,
conservative_gap_cap_seconds=args.conservative_gap_cap_seconds,
tz=tz,
)
if analysis["keptSessionCount"] > 0:
results.append(analysis)
except Exception as error:
print(f"Skipping {child.name}: {error}", file=sys.stderr)
results.sort(key=lambda item: (item.get("retainedSpanStartTsMs") or 0, item.get("taskDir", "")))
if not results:
print("No analyzable tasks found.", file=sys.stderr)
return 1
svg = create_svg(results, args.width, args.height, args.title, args.timezone)
output_path = Path(args.output).expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(svg, encoding="utf-8")
# Also emit a JSON sidecar for reproducibility.
json_path = output_path.with_suffix(".json")
json_path.write_text(json.dumps(results, indent=2), encoding="utf-8")
print(f"Wrote SVG plot to: {output_path}")
print(f"Wrote JSON data to: {json_path}")
print(f"Tasks plotted: {len(results)}")
print(f"Date range: {results[0].get('retainedSpanStartHuman')} -> {results[-1].get('retainedSpanEndHuman')}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+691
View File
@@ -0,0 +1,691 @@
#!/usr/bin/env python3
"""
Report active work sessions for a Cline task directory.
This script derives human-legible activity windows from a task directory by:
1. Loading ui_messages.json and splitting sessions on large inactivity gaps.
2. Filtering out tiny retry/error-only fragments.
3. Mapping each retained session to the most relevant initiating user prompt
from api_conversation_history.json.
Example:
python3 scripts/task_activity_report.py \
"/Users/evekillaby/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/1776446048390"
python3 scripts/task_activity_report.py /path/to/task --json
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
from typing import Any
try:
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover
print("This script requires Python 3.9+ (zoneinfo module).", file=sys.stderr)
sys.exit(1)
SUBSTANTIVE_KINDS = {
"tool",
"text",
"reasoning",
"command",
"task_progress",
"completion_result",
"attempt_completion",
}
RETRY_ONLY_KINDS = {"api_req_failed", "api_req_retried", "error_retry"}
HUMAN_WAIT_ASK_KINDS = {
"command_output",
"completion_result",
"followup",
"resume_task",
"resume_completed_task",
"mistake_limit_reached",
"tool",
"command",
"browser_action_launch",
"use_mcp_server",
"new_task",
"api_req_failed",
"condense",
"summarize_task",
"report_bug",
"use_subagents",
}
@dataclass
class Session:
index: int
start_ts_ms: int
end_ts_ms: int
duration_ms: int
gap_after_ms: int
message_count: int
substantive_message_count: int
initiating_prompt: str
prompt_source_ts_ms: int | None
prompt_source_role: str | None
kept: bool
@dataclass
class WorkSegment:
index: int
session_index: int
start_ts_ms: int
end_ts_ms: int
duration_ms: int
conservative_duration_ms: int
initiating_prompt: str
prompt_source_ts_ms: int | None
prompt_source_role: str | None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Report active work sessions for a Cline task directory")
parser.add_argument("task_dir", help="Path to a task directory containing ui_messages.json and api_conversation_history.json")
parser.add_argument(
"--threshold-minutes",
type=float,
default=15.0,
help="Split sessions when inactivity exceeds this many minutes (default: 15)",
)
parser.add_argument(
"--min-session-minutes",
type=float,
default=2.0,
help="Always keep sessions at or above this duration, even if small (default: 2)",
)
parser.add_argument(
"--timezone",
default="America/Los_Angeles",
help="IANA timezone for human-readable output (default: America/Los_Angeles)",
)
parser.add_argument(
"--conservative-gap-cap-seconds",
type=float,
default=10.0,
help="Cap credited time between successive in-run-loop events for a conservative lower-bound metric (default: 10)",
)
parser.add_argument("--json", action="store_true", help="Emit JSON instead of text")
return parser.parse_args()
def load_json(path: Path) -> Any:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def kind(message: dict[str, Any]) -> str:
return (message.get("ask") or message.get("say") or "").strip()
def message_ts(message: dict[str, Any]) -> int | None:
ts = message.get("ts")
return ts if isinstance(ts, int) else None
def human_ts(ts_ms: int, tz: ZoneInfo) -> str:
return datetime.fromtimestamp(ts_ms / 1000, tz=tz).strftime("%Y-%m-%d %I:%M:%S.%f %p %Z")
def human_duration(ms: int) -> str:
seconds_total = ms / 1000
hours = int(seconds_total // 3600)
seconds_total -= hours * 3600
minutes = int(seconds_total // 60)
seconds_total -= minutes * 60
return f"{hours}h {minutes}m {seconds_total:.3f}s"
def text_blocks_from_api_message(message: dict[str, Any]) -> list[str]:
content = message.get("content")
if isinstance(content, str):
return [content]
if isinstance(content, list):
return [
block.get("text", "")
for block in content
if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str)
]
return []
def clean_whitespace(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def extract_prompt_from_text(text: str) -> str | None:
user_message_match = re.search(r"<user_message>\s*(.*?)\s*</user_message>", text, re.DOTALL)
if user_message_match:
value = clean_whitespace(user_message_match.group(1))
if value:
return value
task_match = re.search(r"<task>\s*(.*?)\s*</task>", text, re.DOTALL)
if task_match:
value = clean_whitespace(task_match.group(1))
if value:
return value
if text.startswith("[TASK RESUMPTION]"):
return clean_whitespace(text)
if "<environment_details>" in text:
return None
value = clean_whitespace(text)
return value or None
def extract_prompt_from_api_message(message: dict[str, Any]) -> str | None:
for text in text_blocks_from_api_message(message):
prompt = extract_prompt_from_text(text)
if prompt:
return prompt
return None
def choose_prompt_for_session(
api_messages: list[dict[str, Any]],
session_start_ms: int,
session_end_ms: int,
is_first_session: bool,
) -> tuple[str, int | None, str | None]:
# Prefer a user prompt inside the session, allowing a small lead-in buffer so the
# user prompt can precede the first UI event in the retained session by a moment.
window_start = session_start_ms - 60_000
for message in api_messages:
if message.get("role") != "user":
continue
ts = message_ts(message)
if ts is None or ts < window_start or ts > session_end_ms:
continue
prompt = extract_prompt_from_api_message(message)
if prompt:
return prompt, ts, str(message.get("role"))
# For the first session, fall back to the task's initial prompt if needed.
if is_first_session:
for message in api_messages:
if message.get("role") != "user":
continue
prompt = extract_prompt_from_api_message(message)
ts = message_ts(message)
if prompt:
return prompt, ts, str(message.get("role"))
return "(no new user prompt for this session; continuation of prior task state)", None, None
def build_sessions(
ui_messages: list[dict[str, Any]],
api_messages: list[dict[str, Any]],
threshold_minutes: float,
min_session_minutes: float,
) -> list[Session]:
threshold_ms = int(threshold_minutes * 60 * 1000)
min_session_ms = int(min_session_minutes * 60 * 1000)
ui = [m for m in ui_messages if message_ts(m) is not None]
ui.sort(key=lambda m: message_ts(m) or 0)
if not ui:
return []
raw_segments: list[tuple[int, int, int, int, int]] = []
start_index = 0
prev_ts = message_ts(ui[0]) or 0
for i, message in enumerate(ui[1:], start=1):
ts = message_ts(message) or 0
if ts - prev_ts > threshold_ms:
raw_segments.append((
start_index,
i - 1,
message_ts(ui[start_index]) or 0,
message_ts(ui[i - 1]) or 0,
ts - prev_ts,
))
start_index = i
prev_ts = ts
raw_segments.append((
start_index,
len(ui) - 1,
message_ts(ui[start_index]) or 0,
message_ts(ui[-1]) or 0,
0,
))
sessions: list[Session] = []
for index, (i0, i1, start_ms, end_ms, gap_after_ms) in enumerate(raw_segments, start=1):
segment_messages = ui[i0 : i1 + 1]
segment_kinds = [kind(m) for m in segment_messages]
substantive_count = sum(1 for k in segment_kinds if k in SUBSTANTIVE_KINDS)
only_retryish = bool(segment_kinds) and all(k in RETRY_ONLY_KINDS for k in segment_kinds)
duration_ms = max(0, end_ms - start_ms)
keep = duration_ms >= min_session_ms or (substantive_count >= 3 and not only_retryish)
prompt, prompt_ts_ms, prompt_role = choose_prompt_for_session(
api_messages,
start_ms,
end_ms,
is_first_session=(index == 1),
)
sessions.append(
Session(
index=index,
start_ts_ms=start_ms,
end_ts_ms=end_ms,
duration_ms=duration_ms,
gap_after_ms=gap_after_ms,
message_count=len(segment_messages),
substantive_message_count=substantive_count,
initiating_prompt=prompt,
prompt_source_ts_ms=prompt_ts_ms,
prompt_source_role=prompt_role,
kept=keep,
)
)
return sessions
def build_work_segments(
ui_messages: list[dict[str, Any]],
api_messages: list[dict[str, Any]],
sessions: list[Session],
conservative_gap_cap_seconds: float,
) -> list[WorkSegment]:
ui = [m for m in ui_messages if message_ts(m) is not None]
ui.sort(key=lambda m: message_ts(m) or 0)
cap_ms = max(0, int(conservative_gap_cap_seconds * 1000))
segments: list[WorkSegment] = []
work_index = 1
for session in sessions:
if not session.kept:
continue
session_messages = [
message
for message in ui
if session.start_ts_ms <= (message_ts(message) or 0) <= session.end_ts_ms
]
if not session_messages:
continue
current_start: int | None = None
last_active_ts: int | None = None
conservative_duration_ms = 0
prev_ts_in_segment: int | None = None
for message in session_messages:
ts = message_ts(message)
if ts is None:
continue
message_kind = kind(message)
is_user_feedback = message.get("type") == "say" and message_kind == "user_feedback"
if is_user_feedback:
if current_start is not None and last_active_ts is not None and last_active_ts >= current_start:
prompt, prompt_ts_ms, prompt_role = choose_prompt_for_session(
api_messages,
current_start,
last_active_ts,
is_first_session=(len(segments) == 0),
)
segments.append(
WorkSegment(
index=work_index,
session_index=session.index,
start_ts_ms=current_start,
end_ts_ms=last_active_ts,
duration_ms=last_active_ts - current_start,
conservative_duration_ms=conservative_duration_ms,
initiating_prompt=prompt,
prompt_source_ts_ms=prompt_ts_ms,
prompt_source_role=prompt_role,
),
)
work_index += 1
current_start = None
last_active_ts = None
conservative_duration_ms = 0
prev_ts_in_segment = None
continue
if current_start is None:
current_start = ts
prev_ts_in_segment = ts
else:
if prev_ts_in_segment is not None:
conservative_duration_ms += min(ts - prev_ts_in_segment, cap_ms)
prev_ts_in_segment = ts
last_active_ts = ts
if message.get("type") == "ask" and message_kind in HUMAN_WAIT_ASK_KINDS:
prompt, prompt_ts_ms, prompt_role = choose_prompt_for_session(
api_messages,
current_start,
ts,
is_first_session=(len(segments) == 0),
)
segments.append(
WorkSegment(
index=work_index,
session_index=session.index,
start_ts_ms=current_start,
end_ts_ms=ts,
duration_ms=ts - current_start,
conservative_duration_ms=conservative_duration_ms,
initiating_prompt=prompt,
prompt_source_ts_ms=prompt_ts_ms,
prompt_source_role=prompt_role,
),
)
work_index += 1
current_start = None
last_active_ts = None
conservative_duration_ms = 0
prev_ts_in_segment = None
if current_start is not None and last_active_ts is not None and last_active_ts >= current_start:
prompt, prompt_ts_ms, prompt_role = choose_prompt_for_session(
api_messages,
current_start,
last_active_ts,
is_first_session=(len(segments) == 0),
)
segments.append(
WorkSegment(
index=work_index,
session_index=session.index,
start_ts_ms=current_start,
end_ts_ms=last_active_ts,
duration_ms=last_active_ts - current_start,
conservative_duration_ms=conservative_duration_ms,
initiating_prompt=prompt,
prompt_source_ts_ms=prompt_ts_ms,
prompt_source_role=prompt_role,
),
)
work_index += 1
return segments
def render_text(
task_dir: Path,
sessions: list[Session],
work_segments: list[WorkSegment],
tz: ZoneInfo,
threshold_minutes: float,
min_session_minutes: float,
conservative_gap_cap_seconds: float,
) -> str:
kept = [session for session in sessions if session.kept]
dropped = [session for session in sessions if not session.kept]
total_session_span_ms = sum(session.duration_ms for session in kept)
total_work_ms = sum(segment.duration_ms for segment in work_segments)
total_conservative_work_ms = sum(segment.conservative_duration_ms for segment in work_segments)
if kept:
first_start = min(session.start_ts_ms for session in kept)
last_end = max(session.end_ts_ms for session in kept)
else:
first_start = None
last_end = None
lines = [
f"Task directory: {task_dir}",
f"Inactivity threshold: {threshold_minutes:.2f} minutes",
f"Minimum kept session: {min_session_minutes:.2f} minutes (unless substantively active)",
f"Retained sessions: {len(kept)} / {len(sessions)}",
f"Total session span: {total_session_span_ms} ms ({human_duration(total_session_span_ms)})",
f"Total agent busy time: {total_work_ms} ms ({human_duration(total_work_ms)})",
f"Total conservative agent busy time: {total_conservative_work_ms} ms ({human_duration(total_conservative_work_ms)})",
f"Conservative gap cap: {conservative_gap_cap_seconds:.2f} seconds between successive in-run-loop events",
"Note: session span includes time inside retained sessions; agent busy time excludes explicit human-wait boundaries such as completion/resume/tool approval asks.",
"The conservative metric is a lower-bound estimate that also caps credited time between successive events inside a work segment.",
]
if first_start is not None and last_end is not None:
lines.extend(
[
f"Retained sessions span: {human_ts(first_start, tz)} -> {human_ts(last_end, tz)}",
f"Retained session span unix ms: {first_start} -> {last_end}",
"Note: this span is not continuous; rely on the individual sessions below for actual active windows.",
],
)
lines.append("")
lines.append("Sessions:")
for session in kept:
lines.extend(
[
f"- Session {session.index}",
f" Start: {human_ts(session.start_ts_ms, tz)} ({session.start_ts_ms})",
f" End: {human_ts(session.end_ts_ms, tz)} ({session.end_ts_ms})",
f" Duration: {session.duration_ms} ms ({human_duration(session.duration_ms)})",
f" Gap after: {session.gap_after_ms} ms ({human_duration(session.gap_after_ms)})",
f" Messages: {session.message_count}, substantive: {session.substantive_message_count}",
f" Prompt source ts: {session.prompt_source_ts_ms if session.prompt_source_ts_ms is not None else 'n/a'}",
f" Prompt: {session.initiating_prompt}",
],
)
if dropped:
lines.append("")
lines.append("Dropped sessions:")
for session in dropped:
lines.append(
f"- Session {session.index}: {human_ts(session.start_ts_ms, tz)} -> {human_ts(session.end_ts_ms, tz)} | {human_duration(session.duration_ms)} | messages={session.message_count} | substantive={session.substantive_message_count}",
)
lines.append("")
lines.append("Work segments (stricter agent-busy windows):")
for segment in work_segments:
lines.extend(
[
f"- Work segment {segment.index} (from session {segment.session_index})",
f" Start: {human_ts(segment.start_ts_ms, tz)} ({segment.start_ts_ms})",
f" End: {human_ts(segment.end_ts_ms, tz)} ({segment.end_ts_ms})",
f" Duration: {segment.duration_ms} ms ({human_duration(segment.duration_ms)})",
f" Conservative duration: {segment.conservative_duration_ms} ms ({human_duration(segment.conservative_duration_ms)})",
f" Prompt source ts: {segment.prompt_source_ts_ms if segment.prompt_source_ts_ms is not None else 'n/a'}",
f" Prompt: {segment.initiating_prompt}",
],
)
return "\n".join(lines)
def render_json(
task_dir: Path,
sessions: list[Session],
work_segments: list[WorkSegment],
tz: ZoneInfo,
threshold_minutes: float,
min_session_minutes: float,
conservative_gap_cap_seconds: float,
) -> str:
kept = [session for session in sessions if session.kept]
total_session_span_ms = sum(session.duration_ms for session in kept)
total_work_ms = sum(segment.duration_ms for segment in work_segments)
total_conservative_work_ms = sum(segment.conservative_duration_ms for segment in work_segments)
output = {
"taskDir": str(task_dir),
"thresholdMinutes": threshold_minutes,
"minSessionMinutes": min_session_minutes,
"conservativeGapCapSeconds": conservative_gap_cap_seconds,
"timezone": str(tz),
"totalSessionSpanMs": total_session_span_ms,
"totalSessionSpanHuman": human_duration(total_session_span_ms),
"totalAgentBusyMs": total_work_ms,
"totalAgentBusyHuman": human_duration(total_work_ms),
"totalConservativeAgentBusyMs": total_conservative_work_ms,
"totalConservativeAgentBusyHuman": human_duration(total_conservative_work_ms),
"keptSessionCount": len(kept),
"allSessionCount": len(sessions),
"sessions": [
{
**asdict(session),
"startHuman": human_ts(session.start_ts_ms, tz),
"endHuman": human_ts(session.end_ts_ms, tz),
"durationHuman": human_duration(session.duration_ms),
"gapAfterHuman": human_duration(session.gap_after_ms),
}
for session in sessions
],
"workSegments": [
{
**asdict(segment),
"startHuman": human_ts(segment.start_ts_ms, tz),
"endHuman": human_ts(segment.end_ts_ms, tz),
"durationHuman": human_duration(segment.duration_ms),
"conservativeDurationHuman": human_duration(segment.conservative_duration_ms),
}
for segment in work_segments
],
}
return json.dumps(output, indent=2)
def analyze_task_directory(
task_dir: Path,
threshold_minutes: float,
min_session_minutes: float,
conservative_gap_cap_seconds: float,
tz: ZoneInfo,
) -> dict[str, Any]:
ui_path = task_dir / "ui_messages.json"
api_path = task_dir / "api_conversation_history.json"
ui_messages = load_json(ui_path)
api_messages = load_json(api_path)
sessions = build_sessions(
ui_messages=ui_messages,
api_messages=api_messages,
threshold_minutes=threshold_minutes,
min_session_minutes=min_session_minutes,
)
work_segments = build_work_segments(
ui_messages=ui_messages,
api_messages=api_messages,
sessions=sessions,
conservative_gap_cap_seconds=conservative_gap_cap_seconds,
)
kept = [session for session in sessions if session.kept]
total_session_span_ms = sum(session.duration_ms for session in kept)
total_work_ms = sum(segment.duration_ms for segment in work_segments)
total_conservative_work_ms = sum(segment.conservative_duration_ms for segment in work_segments)
first_start = min((session.start_ts_ms for session in kept), default=None)
last_end = max((session.end_ts_ms for session in kept), default=None)
return {
"taskDir": str(task_dir),
"thresholdMinutes": threshold_minutes,
"minSessionMinutes": min_session_minutes,
"conservativeGapCapSeconds": conservative_gap_cap_seconds,
"timezone": str(tz),
"totalSessionSpanMs": total_session_span_ms,
"totalSessionSpanHuman": human_duration(total_session_span_ms),
"totalAgentBusyMs": total_work_ms,
"totalAgentBusyHuman": human_duration(total_work_ms),
"totalConservativeAgentBusyMs": total_conservative_work_ms,
"totalConservativeAgentBusyHuman": human_duration(total_conservative_work_ms),
"keptSessionCount": len(kept),
"allSessionCount": len(sessions),
"retainedSpanStartTsMs": first_start,
"retainedSpanEndTsMs": last_end,
"retainedSpanStartHuman": human_ts(first_start, tz) if first_start is not None else None,
"retainedSpanEndHuman": human_ts(last_end, tz) if last_end is not None else None,
"sessions": [
{
**asdict(session),
"startHuman": human_ts(session.start_ts_ms, tz),
"endHuman": human_ts(session.end_ts_ms, tz),
"durationHuman": human_duration(session.duration_ms),
"gapAfterHuman": human_duration(session.gap_after_ms),
}
for session in sessions
],
"workSegments": [
{
**asdict(segment),
"startHuman": human_ts(segment.start_ts_ms, tz),
"endHuman": human_ts(segment.end_ts_ms, tz),
"durationHuman": human_duration(segment.duration_ms),
"conservativeDurationHuman": human_duration(segment.conservative_duration_ms),
}
for segment in work_segments
],
}
def main() -> int:
args = parse_args()
task_dir = Path(args.task_dir).expanduser().resolve()
ui_path = task_dir / "ui_messages.json"
api_path = task_dir / "api_conversation_history.json"
if not ui_path.exists() or not api_path.exists():
print(
f"Task directory must contain both ui_messages.json and api_conversation_history.json: {task_dir}",
file=sys.stderr,
)
return 1
try:
tz = ZoneInfo(args.timezone)
except Exception as error:
print(f"Invalid timezone '{args.timezone}': {error}", file=sys.stderr)
return 1
analysis = analyze_task_directory(
task_dir=task_dir,
threshold_minutes=args.threshold_minutes,
min_session_minutes=args.min_session_minutes,
conservative_gap_cap_seconds=args.conservative_gap_cap_seconds,
tz=tz,
)
if args.json:
print(json.dumps(analysis, indent=2))
else:
print(
render_text(
task_dir,
[Session(**{k: v for k, v in s.items() if k in Session.__dataclass_fields__}) for s in analysis["sessions"]],
[
WorkSegment(**{k: v for k, v in w.items() if k in WorkSegment.__dataclass_fields__})
for w in analysis["workSegments"]
],
tz,
args.threshold_minutes,
args.min_session_minutes,
args.conservative_gap_cap_seconds,
),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Aggregate task activity metrics across a tasks root directory.
This script reuses scripts/task_activity_report.py to analyze every task directory
under a given root and sorts the tasks by a chosen horizon metric.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from zoneinfo import ZoneInfo
from task_activity_report import analyze_task_directory, human_duration
SORT_KEYS = {
"conservative": "totalConservativeAgentBusyMs",
"agent-busy": "totalAgentBusyMs",
"session-span": "totalSessionSpanMs",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Roll up Cline task activity across a tasks directory")
parser.add_argument("tasks_root", help="Directory containing per-task subdirectories")
parser.add_argument("--threshold-minutes", type=float, default=15.0)
parser.add_argument("--min-session-minutes", type=float, default=2.0)
parser.add_argument("--conservative-gap-cap-seconds", type=float, default=10.0)
parser.add_argument("--timezone", default="America/Los_Angeles")
parser.add_argument(
"--sort-by",
choices=sorted(SORT_KEYS.keys()),
default="conservative",
help="Metric used to rank longest-horizon tasks (default: conservative)",
)
parser.add_argument("--limit", type=int, default=25, help="Maximum number of tasks to print")
parser.add_argument("--json", action="store_true", help="Emit JSON instead of text")
return parser.parse_args()
def is_task_dir(path: Path) -> bool:
return (path / "ui_messages.json").exists() and (path / "api_conversation_history.json").exists()
def summarize_prompt(task_analysis: dict) -> str:
for session in task_analysis.get("sessions", []):
if session.get("kept") and session.get("initiating_prompt"):
return session["initiating_prompt"]
return "(no prompt found)"
def render_text(results: list[dict], sort_by: str, limit: int) -> str:
metric_key = SORT_KEYS[sort_by]
lines = [
f"Tasks ranked by {sort_by} ({metric_key})",
"",
]
for idx, result in enumerate(results[:limit], start=1):
lines.extend(
[
f"{idx}. {Path(result['taskDir']).name}",
f" conservative: {result['totalConservativeAgentBusyHuman']} ({result['totalConservativeAgentBusyMs']} ms)",
f" agent busy: {result['totalAgentBusyHuman']} ({result['totalAgentBusyMs']} ms)",
f" session span: {result['totalSessionSpanHuman']} ({result['totalSessionSpanMs']} ms)",
f" retained sessions: {result['keptSessionCount']}",
f" retained span: {result.get('retainedSpanStartHuman') or 'n/a'} -> {result.get('retainedSpanEndHuman') or 'n/a'}",
f" prompt: {summarize_prompt(result)}",
]
)
return "\n".join(lines)
def main() -> int:
args = parse_args()
root = Path(args.tasks_root).expanduser().resolve()
if not root.exists() or not root.is_dir():
print(f"Tasks root must be an existing directory: {root}", file=sys.stderr)
return 1
try:
tz = ZoneInfo(args.timezone)
except Exception as error:
print(f"Invalid timezone '{args.timezone}': {error}", file=sys.stderr)
return 1
results = []
for child in sorted(root.iterdir()):
if not child.is_dir() or not is_task_dir(child):
continue
try:
results.append(
analyze_task_directory(
task_dir=child,
threshold_minutes=args.threshold_minutes,
min_session_minutes=args.min_session_minutes,
conservative_gap_cap_seconds=args.conservative_gap_cap_seconds,
tz=tz,
),
)
except Exception as error:
results.append(
{
"taskDir": str(child),
"error": str(error),
"totalConservativeAgentBusyMs": -1,
"totalAgentBusyMs": -1,
"totalSessionSpanMs": -1,
},
)
metric_key = SORT_KEYS[args.sort_by]
results.sort(key=lambda item: item.get(metric_key, -1), reverse=True)
if args.json:
print(json.dumps(results, indent=2))
else:
print(render_text(results, args.sort_by, args.limit))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+18 -2
View File
@@ -39,7 +39,9 @@ export async function subscribeToState(
const initialState = await controller.getStateToPostToWebview()
const initialStateJson = JSON.stringify(initialState)
recordStateSizeTelemetry(Buffer.byteLength(initialStateJson, "utf8"))
const initialStateSize = Buffer.byteLength(initialStateJson, "utf8")
recordStateSizeTelemetry(initialStateSize)
logLargeStatePayload(initialState, initialStateSize, "initial")
try {
await responseStream(
@@ -67,7 +69,9 @@ export async function sendStateUpdate(state: ExtensionState): Promise<void> {
return
}
recordStateSizeTelemetry(Buffer.byteLength(stateJson, "utf8"))
const stateSize = Buffer.byteLength(stateJson, "utf8")
recordStateSizeTelemetry(stateSize)
logLargeStatePayload(state, stateSize, "update")
const promises = Array.from(activeStateSubscriptions).map(async (responseStream) => {
try {
@@ -89,3 +93,15 @@ export async function sendStateUpdate(state: ExtensionState): Promise<void> {
function recordStateSizeTelemetry(sizeBytes: number): void {
telemetryService.captureGrpcResponseSize(sizeBytes, "cline.StateService", "subscribeToState")
}
function logLargeStatePayload(state: ExtensionState, sizeBytes: number, phase: "initial" | "update"): void {
const LARGE_STATE_WARNING_BYTES = 1024 * 1024
if (sizeBytes < LARGE_STATE_WARNING_BYTES) {
return
}
Logger.warn(
`[StateService] Large ${phase} payload (${(sizeBytes / (1024 * 1024)).toFixed(2)} MiB, messages=${state.clineMessages.length}, taskHistory=${state.taskHistory.length})`,
)
}
+93
View File
@@ -0,0 +1,93 @@
import { fireEvent, render, screen } from "@testing-library/react"
import type React from "react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import App from "./App"
import { useClineAuth } from "./context/ClineAuthContext"
import { useExtensionState } from "./context/ExtensionStateContext"
vi.mock("./Providers", () => ({
Providers: ({ children }: { children: React.ReactNode }) => children,
}))
vi.mock("./context/ClineAuthContext", () => ({
useClineAuth: vi.fn(),
}))
vi.mock("./context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(),
}))
function createExtensionStateMock(overrides: Record<string, unknown> = {}) {
return {
didHydrateState: false,
webviewBootstrapAttempt: 1,
webviewBootstrapError: undefined,
webviewBootstrapStatus: "hydrating",
showWelcome: false,
shouldShowAnnouncement: false,
dismissedBanners: [],
showMcp: false,
mcpTab: undefined,
showSettings: false,
settingsTargetSection: undefined,
showHistory: false,
showAccount: false,
showWorktrees: false,
showAnnouncement: false,
onboardingModels: undefined,
setShowAnnouncement: vi.fn(),
setShouldShowAnnouncement: vi.fn(),
closeMcpView: vi.fn(),
navigateToHistory: vi.fn(),
hideSettings: vi.fn(),
hideHistory: vi.fn(),
hideAccount: vi.fn(),
hideWorktrees: vi.fn(),
hideAnnouncement: vi.fn(),
reloadWebview: vi.fn(),
retryWebviewBootstrap: vi.fn(),
...overrides,
}
}
describe("App grey-screen regression coverage", () => {
beforeEach(() => {
vi.mocked(useClineAuth).mockReturnValue({
clineUser: null,
organizations: null,
activeOrganization: null,
} as any)
})
it("renders a visible loading screen before hydration instead of returning null", () => {
vi.mocked(useExtensionState).mockReturnValue(createExtensionStateMock() as any)
render(<App />)
expect(screen.getByText("Loading Cline")).toBeTruthy()
expect(screen.getByText("Waiting for the extension to send the initial Cline state…")).toBeTruthy()
})
it("renders recovery actions when bootstrap degrades", () => {
const retryWebviewBootstrap = vi.fn()
const reloadWebview = vi.fn()
vi.mocked(useExtensionState).mockReturnValue(
createExtensionStateMock({
webviewBootstrapStatus: "degraded",
webviewBootstrapError: "Timed out waiting for the initial Cline state after 8 seconds.",
retryWebviewBootstrap,
reloadWebview,
}) as any,
)
render(<App />)
expect(screen.getByText("Cline is having trouble loading")).toBeTruthy()
fireEvent.click(screen.getByRole("button", { name: "Retry connection" }))
fireEvent.click(screen.getByRole("button", { name: "Reload webview" }))
expect(retryWebviewBootstrap).toHaveBeenCalledTimes(1)
expect(reloadWebview).toHaveBeenCalledTimes(1)
})
})
+30 -3
View File
@@ -1,8 +1,9 @@
import type { Boolean, EmptyRequest } from "@shared/proto/cline/common"
import type { Boolean as BooleanResponse, EmptyRequest } from "@shared/proto/cline/common"
import { useCallback, useEffect, useState } from "react"
import AccountView from "./components/account/AccountView"
import ChatView from "./components/chat/ChatView"
import ClineKanbanLaunchModal, { CLINE_KANBAN_MODAL_DISMISS_ID } from "./components/common/ClineKanbanLaunchModal"
import WebviewStatus from "./components/common/WebviewStatus"
import HistoryView from "./components/history/HistoryView"
import McpView from "./components/mcp/configuration/McpConfigurationView"
import OnboardingView from "./components/onboarding/OnboardingView"
@@ -17,6 +18,9 @@ import { StateServiceClient, UiServiceClient } from "./services/grpc-client"
const AppContent = () => {
const {
didHydrateState,
webviewBootstrapAttempt,
webviewBootstrapError,
webviewBootstrapStatus,
showWelcome,
shouldShowAnnouncement,
dismissedBanners,
@@ -38,6 +42,8 @@ const AppContent = () => {
hideAccount,
hideWorktrees,
hideAnnouncement,
reloadWebview,
retryWebviewBootstrap,
} = useExtensionState()
const [showKanbanModal, setShowKanbanModal] = useState(false)
const [hasShownKanbanModal, setHasShownKanbanModal] = useState(false)
@@ -47,7 +53,7 @@ const AppContent = () => {
const showUpdateAnnouncementModal = useCallback(() => {
setShowAnnouncement(true)
UiServiceClient.onDidShowAnnouncement({} as EmptyRequest)
.then((response: Boolean) => {
.then((response: BooleanResponse) => {
setShouldShowAnnouncement(response.value)
})
.catch((error) => {
@@ -96,7 +102,28 @@ const AppContent = () => {
}, [])
if (!didHydrateState) {
return null
return (
<WebviewStatus
description={
webviewBootstrapStatus === "degraded"
? "The webview did not receive its initial state correctly. You can retry the connection or reload just this webview."
: "Waiting for the extension to send the initial Cline state…"
}
details={
webviewBootstrapStatus === "degraded"
? [webviewBootstrapError, `Attempts: ${webviewBootstrapAttempt}`]
.filter((value): value is string => Boolean(value))
.join("\n")
: webviewBootstrapAttempt > 1
? `Connection attempt ${webviewBootstrapAttempt}`
: undefined
}
isLoading={webviewBootstrapStatus !== "degraded"}
onReload={reloadWebview}
onRetry={retryWebviewBootstrap}
title={webviewBootstrapStatus === "degraded" ? "Cline is having trouble loading" : "Loading Cline"}
/>
)
}
if (showWelcome) {
@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import WebviewErrorBoundary from "./WebviewErrorBoundary"
const ThrowingComponent = () => {
throw new Error("boom")
}
describe("WebviewErrorBoundary", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it("renders children when no error is thrown", () => {
render(
<WebviewErrorBoundary>
<div>Healthy child</div>
</WebviewErrorBoundary>,
)
expect(screen.getByText("Healthy child")).toBeTruthy()
})
it("renders a recovery UI when a child throws", () => {
render(
<WebviewErrorBoundary>
<ThrowingComponent />
</WebviewErrorBoundary>,
)
expect(screen.getByText("Cline webview crashed")).toBeTruthy()
expect(screen.getByRole("button", { name: "Reload webview" })).toBeTruthy()
})
})
@@ -0,0 +1,48 @@
import React from "react"
import WebviewStatus from "./WebviewStatus"
interface WebviewErrorBoundaryProps {
children: React.ReactNode
}
interface WebviewErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class WebviewErrorBoundary extends React.Component<WebviewErrorBoundaryProps, WebviewErrorBoundaryState> {
constructor(props: WebviewErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): WebviewErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("[WebviewErrorBoundary] Uncaught root error:", error)
console.error("[WebviewErrorBoundary] Component stack:", errorInfo.componentStack)
}
private reloadWebview = () => {
window.location.reload()
}
render() {
if (this.state.hasError) {
return (
<WebviewStatus
description="The UI hit an unexpected error while rendering. Reload the webview to recover without reloading the full VS Code window."
details={this.state.error?.stack || this.state.error?.message}
onReload={this.reloadWebview}
title="Cline webview crashed"
/>
)
}
return this.props.children
}
}
export default WebviewErrorBoundary
@@ -0,0 +1,39 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import WebviewStatus from "./WebviewStatus"
describe("WebviewStatus", () => {
it("renders a loading state", () => {
render(
<WebviewStatus
description="Waiting for state"
isLoading
onReload={vi.fn()}
onRetry={vi.fn()}
title="Loading Cline"
/>,
)
expect(screen.getByText("Loading Cline")).toBeTruthy()
expect(screen.getByText("Waiting for state")).toBeTruthy()
expect(screen.getByRole("button", { name: "Retry connection" })).toBeTruthy()
expect(screen.getByRole("button", { name: "Reload webview" })).toBeTruthy()
})
it("shows details and invokes retry", () => {
const onRetry = vi.fn()
render(
<WebviewStatus
description="Retry it"
details="Timed out waiting for initial state"
onRetry={onRetry}
title="Cline is having trouble loading"
/>,
)
expect(screen.getByText("Timed out waiting for initial state")).toBeTruthy()
fireEvent.click(screen.getByRole("button", { name: "Retry connection" }))
expect(onRetry).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,56 @@
import { Button } from "@/components/ui/button"
interface WebviewStatusProps {
title: string
description: string
details?: string
isLoading?: boolean
onRetry?: () => void
onReload?: () => void
retryLabel?: string
}
export const WebviewStatus = ({
title,
description,
details,
isLoading = false,
onRetry,
onReload,
retryLabel = "Retry connection",
}: WebviewStatusProps) => {
return (
<div className="flex h-screen w-full items-center justify-center bg-background text-foreground">
<div className="mx-4 flex w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-background p-5 shadow-sm">
<div className="flex items-center gap-2 text-base font-semibold">
{isLoading ? (
<i className="codicon codicon-loading codicon-modifier-spin text-link" />
) : (
<i className="codicon codicon-warning text-link" />
)}
<span>{title}</span>
</div>
<p className="m-0 text-sm text-[var(--vscode-descriptionForeground)]">{description}</p>
{details ? (
<pre className="m-0 overflow-auto rounded-md border border-border bg-code p-3 text-xs whitespace-pre-wrap text-[var(--vscode-descriptionForeground)]">
{details}
</pre>
) : null}
<div className="flex flex-wrap gap-2 pt-1">
{onRetry ? (
<Button onClick={onRetry} size="sm">
{retryLabel}
</Button>
) : null}
{onReload ? (
<Button onClick={onReload} size="sm" variant="secondary">
Reload webview
</Button>
) : null}
</div>
</div>
</div>
)
}
export default WebviewStatus
@@ -0,0 +1,251 @@
import { act, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../../src/shared/AutoApprovalSettings"
import { DEFAULT_BROWSER_SETTINGS } from "../../../src/shared/BrowserSettings"
import { Environment } from "../../../src/shared/config-types"
import { DEFAULT_PLATFORM, type ExtensionState } from "../../../src/shared/ExtensionMessage"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "../../../src/shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "../../../src/shared/McpDisplayMode"
import { ExtensionStateContextProvider, useExtensionState } from "./ExtensionStateContext"
const grpcClientMocks = vi.hoisted(() => ({
subscribeToStateMock: vi.fn(),
initializeWebviewMock: vi.fn(),
genericStreamSubscriptionMock: vi.fn(),
getAvailableTerminalProfilesMock: vi.fn(),
modelsClientMock: {
subscribeToOpenRouterModels: vi.fn(),
subscribeToLiteLlmModels: vi.fn(),
refreshOpenRouterModelsRpc: vi.fn(),
refreshVercelAiGatewayModelsRpc: vi.fn(),
refreshLiteLlmModelsRpc: vi.fn(),
refreshClineModelsRpc: vi.fn(),
refreshHicapModels: vi.fn(),
refreshBasetenModelsRpc: vi.fn(),
},
}))
vi.mock("@/services/grpc-client", () => ({
StateServiceClient: {
subscribeToState: grpcClientMocks.subscribeToStateMock,
getAvailableTerminalProfiles: grpcClientMocks.getAvailableTerminalProfilesMock,
},
UiServiceClient: {
subscribeToMcpButtonClicked: grpcClientMocks.genericStreamSubscriptionMock,
subscribeToHistoryButtonClicked: grpcClientMocks.genericStreamSubscriptionMock,
subscribeToChatButtonClicked: grpcClientMocks.genericStreamSubscriptionMock,
subscribeToSettingsButtonClicked: grpcClientMocks.genericStreamSubscriptionMock,
subscribeToWorktreesButtonClicked: grpcClientMocks.genericStreamSubscriptionMock,
subscribeToPartialMessage: grpcClientMocks.genericStreamSubscriptionMock,
subscribeToAccountButtonClicked: grpcClientMocks.genericStreamSubscriptionMock,
subscribeToRelinquishControl: grpcClientMocks.genericStreamSubscriptionMock,
initializeWebview: grpcClientMocks.initializeWebviewMock,
},
McpServiceClient: {
subscribeToMcpServers: grpcClientMocks.genericStreamSubscriptionMock,
subscribeToMcpMarketplaceCatalog: grpcClientMocks.genericStreamSubscriptionMock,
},
ModelsServiceClient: grpcClientMocks.modelsClientMock,
}))
const {
subscribeToStateMock,
initializeWebviewMock,
genericStreamSubscriptionMock,
getAvailableTerminalProfilesMock,
modelsClientMock,
} = grpcClientMocks
function createBaseState(overrides: Partial<ExtensionState> = {}): ExtensionState {
return {
version: "test-version",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
autoApprovalSettings: DEFAULT_AUTO_APPROVAL_SETTINGS,
browserSettings: DEFAULT_BROWSER_SETTINGS,
focusChainSettings: DEFAULT_FOCUS_CHAIN_SETTINGS,
preferredLanguage: "English",
mode: "act",
platform: DEFAULT_PLATFORM,
environment: Environment.production,
telemetrySetting: "unset",
distinctId: "distinct-id",
planActSeparateModelsSetting: true,
enableCheckpointsSetting: true,
mcpDisplayMode: DEFAULT_MCP_DISPLAY_MODE,
globalClineRulesToggles: {},
localClineRulesToggles: {},
localCursorRulesToggles: {},
localWindsurfRulesToggles: {},
localAgentsRulesToggles: {},
localWorkflowToggles: {},
globalWorkflowToggles: {},
shellIntegrationTimeout: 4000,
terminalReuseEnabled: true,
vscodeTerminalExecutionMode: "vscodeTerminal",
terminalOutputLineLimit: 500,
maxConsecutiveMistakes: 3,
defaultTerminalProfile: "default",
isNewUser: false,
welcomeViewCompleted: true,
onboardingModels: undefined,
mcpResponsesCollapsed: false,
strictPlanModeEnabled: false,
yoloModeToggled: false,
customPrompt: undefined,
useAutoCondense: false,
subagentsEnabled: false,
clineWebToolsEnabled: { user: true, featureFlag: false },
worktreesEnabled: { user: true, featureFlag: false },
favoritedModelIds: [],
lastDismissedInfoBannerVersion: 0,
lastDismissedModelBannerVersion: 0,
lastDismissedCliBannerVersion: 0,
optOutOfRemoteConfig: false,
remoteConfigSettings: {},
backgroundCommandRunning: false,
backgroundCommandTaskId: undefined,
backgroundEditEnabled: false,
doubleCheckCompletionEnabled: false,
lazyTeammateModeEnabled: false,
showFeatureTips: true,
globalSkillsToggles: {},
localSkillsToggles: {},
workspaceRoots: [],
primaryRootIndex: 0,
isMultiRootWorkspace: false,
multiRootSetting: { user: false, featureFlag: false },
hooksEnabled: false,
nativeToolCallSetting: false,
enableParallelToolCalling: false,
...overrides,
}
}
function flushPromises() {
return act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
const Harness = () => {
const { didHydrateState, webviewBootstrapAttempt, webviewBootstrapError, webviewBootstrapStatus, retryWebviewBootstrap } =
useExtensionState()
return (
<div>
<div data-testid="status">{webviewBootstrapStatus}</div>
<div data-testid="hydrated">{String(didHydrateState)}</div>
<div data-testid="attempt">{webviewBootstrapAttempt}</div>
<div data-testid="error">{webviewBootstrapError ?? ""}</div>
<button onClick={retryWebviewBootstrap} type="button">
Retry bootstrap
</button>
</div>
)
}
describe("ExtensionStateContext grey-screen regression coverage", () => {
beforeEach(() => {
vi.useFakeTimers()
subscribeToStateMock.mockReset()
initializeWebviewMock.mockReset()
genericStreamSubscriptionMock.mockReset()
getAvailableTerminalProfilesMock.mockReset()
subscribeToStateMock.mockImplementation(() => vi.fn())
initializeWebviewMock.mockResolvedValue({})
genericStreamSubscriptionMock.mockImplementation(() => vi.fn())
getAvailableTerminalProfilesMock.mockResolvedValue({ profiles: [] })
for (const fn of Object.values(modelsClientMock)) {
fn.mockReset()
}
modelsClientMock.subscribeToOpenRouterModels.mockImplementation(() => vi.fn())
modelsClientMock.subscribeToLiteLlmModels.mockImplementation(() => vi.fn())
modelsClientMock.refreshOpenRouterModelsRpc.mockResolvedValue({ models: {} })
modelsClientMock.refreshVercelAiGatewayModelsRpc.mockResolvedValue({ models: {} })
modelsClientMock.refreshLiteLlmModelsRpc.mockResolvedValue({ models: {} })
modelsClientMock.refreshClineModelsRpc.mockResolvedValue({ models: {} })
modelsClientMock.refreshHicapModels.mockResolvedValue({ models: {} })
modelsClientMock.refreshBasetenModelsRpc.mockResolvedValue({ models: {} })
})
afterEach(() => {
vi.useRealTimers()
})
it("transitions to degraded instead of hanging forever when initial state never arrives", async () => {
render(
<ExtensionStateContextProvider>
<Harness />
</ExtensionStateContextProvider>,
)
await flushPromises()
expect(screen.getByTestId("status").textContent).toBe("hydrating")
act(() => {
vi.advanceTimersByTime(8000)
})
await flushPromises()
expect(screen.getByTestId("status").textContent).toBe("degraded")
expect(screen.getByTestId("hydrated").textContent).toBe("false")
expect(screen.getByTestId("error").textContent).toContain("Timed out waiting for the initial Cline state")
})
it("shows a degraded state when the initial state payload is malformed", async () => {
render(
<ExtensionStateContextProvider>
<Harness />
</ExtensionStateContextProvider>,
)
await flushPromises()
const callbacks = subscribeToStateMock.mock.calls[0]?.[1]
expect(callbacks).toBeTruthy()
await act(async () => {
callbacks.onResponse({ stateJson: "{" })
})
await flushPromises()
expect(screen.getByTestId("status").textContent).toBe("degraded")
expect(screen.getByTestId("error").textContent).toContain("Received invalid initial state payload")
})
it("recovers after a manual retry when a later state payload succeeds", async () => {
render(
<ExtensionStateContextProvider>
<Harness />
</ExtensionStateContextProvider>,
)
await flushPromises()
const firstCallbacks = subscribeToStateMock.mock.calls[0]?.[1]
await act(async () => {
firstCallbacks.onResponse({ stateJson: "{" })
})
await flushPromises()
fireEvent.click(screen.getByRole("button", { name: "Retry bootstrap" }))
await flushPromises()
expect(subscribeToStateMock).toHaveBeenCalledTimes(2)
const secondCallbacks = subscribeToStateMock.mock.calls[1]?.[1]
await act(async () => {
secondCallbacks.onResponse({ stateJson: JSON.stringify(createBaseState()) })
})
await flushPromises()
expect(screen.getByTestId("status").textContent).toBe("hydrated")
expect(screen.getByTestId("hydrated").textContent).toBe("true")
expect(screen.getByTestId("attempt").textContent).toBe("2")
})
})
+189 -62
View File
@@ -30,6 +30,9 @@ import { McpServiceClient, ModelsServiceClient, StateServiceClient, UiServiceCli
export interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
webviewBootstrapStatus: "hydrating" | "hydrated" | "degraded"
webviewBootstrapError?: string
webviewBootstrapAttempt: number
showWelcome: boolean
onboardingModels: OnboardingModelGroup | undefined
clineModels: Record<string, ModelInfo> | null
@@ -118,6 +121,8 @@ export interface ExtensionStateContextType extends ExtensionState {
// Event callbacks
onRelinquishControl: (callback: () => void) => () => void
retryWebviewBootstrap: () => void
reloadWebview: () => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -125,6 +130,10 @@ export const ExtensionStateContext = createContext<ExtensionStateContextType | u
export const ExtensionStateContextProvider: React.FC<{
children: React.ReactNode
}> = ({ children }) => {
const HYDRATION_TIMEOUT_MS = 8000
const MAX_BOOTSTRAP_AUTO_RETRIES = 2
const BOOTSTRAP_RETRY_DELAY_MS = 1000
// UI view state
const [showMcp, setShowMcp] = useState(false)
const [mcpTab, setMcpTab] = useState<McpViewTab | undefined>(undefined)
@@ -293,6 +302,9 @@ export const ExtensionStateContextProvider: React.FC<{
})
const [expandTaskHeader, setExpandTaskHeader] = useState(true)
const [didHydrateState, setDidHydrateState] = useState(false)
const [webviewBootstrapStatus, setWebviewBootstrapStatus] = useState<"hydrating" | "hydrated" | "degraded">("hydrating")
const [webviewBootstrapError, setWebviewBootstrapError] = useState<string | undefined>(undefined)
const [webviewBootstrapAttempt, setWebviewBootstrapAttempt] = useState(0)
const [showWelcome, setShowWelcome] = useState(false)
const [onboardingModels, setOnboardingModels] = useState<OnboardingModelGroup | undefined>(undefined)
@@ -337,10 +349,179 @@ export const ExtensionStateContextProvider: React.FC<{
const liteLlmModelsUnsubscribeRef = useRef<(() => void) | null>(null)
const workspaceUpdatesUnsubscribeRef = useRef<(() => void) | null>(null)
const relinquishControlUnsubscribeRef = useRef<(() => void) | null>(null)
const hydrationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const bootstrapRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const didHydrateStateRef = useRef(false)
const bootstrapAttemptRef = useRef(0)
const showWelcomeRef = useRef(false)
// Add ref for callbacks
const relinquishControlCallbacks = useRef<Set<() => void>>(new Set())
useEffect(() => {
didHydrateStateRef.current = didHydrateState
}, [didHydrateState])
useEffect(() => {
showWelcomeRef.current = showWelcome
}, [showWelcome])
const clearBootstrapTimers = useCallback(() => {
if (hydrationTimeoutRef.current) {
clearTimeout(hydrationTimeoutRef.current)
hydrationTimeoutRef.current = null
}
if (bootstrapRetryTimeoutRef.current) {
clearTimeout(bootstrapRetryTimeoutRef.current)
bootstrapRetryTimeoutRef.current = null
}
}, [])
const reloadWebview = useCallback(() => {
window.location.reload()
}, [])
const startStateHydration = useCallback(
(reason = "initial") => {
clearBootstrapTimers()
if (stateSubscriptionRef.current) {
stateSubscriptionRef.current()
stateSubscriptionRef.current = null
}
const nextAttempt = bootstrapAttemptRef.current + 1
bootstrapAttemptRef.current = nextAttempt
setWebviewBootstrapAttempt(nextAttempt)
setWebviewBootstrapStatus("hydrating")
setWebviewBootstrapError(undefined)
if (!didHydrateStateRef.current) {
setDidHydrateState(false)
}
console.log(`[WEBVIEW] Starting initial state hydration attempt ${nextAttempt} (${reason})`)
const scheduleRetry = (retryReason: string) => {
if (
didHydrateStateRef.current ||
bootstrapRetryTimeoutRef.current ||
bootstrapAttemptRef.current >= MAX_BOOTSTRAP_AUTO_RETRIES
) {
return
}
bootstrapRetryTimeoutRef.current = setTimeout(() => {
bootstrapRetryTimeoutRef.current = null
startStateHydration(retryReason)
}, BOOTSTRAP_RETRY_DELAY_MS)
}
stateSubscriptionRef.current = StateServiceClient.subscribeToState(EmptyRequest.create({}), {
onResponse: (response) => {
if (response.stateJson) {
try {
const stateData = JSON.parse(response.stateJson) as ExtensionState
setState((prevState) => {
// Versioning logic for autoApprovalSettings
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
const currentVersion = prevState.autoApprovalSettings?.version ?? 1
const shouldUpdateAutoApproval = incomingVersion > currentVersion
// HACK: Preserve clineMessages if currentTaskItem is the same
if (stateData.currentTaskItem?.id === prevState.currentTaskItem?.id) {
stateData.clineMessages = stateData.clineMessages?.length
? stateData.clineMessages
: prevState.clineMessages
}
const newState = {
...stateData,
autoApprovalSettings: shouldUpdateAutoApproval
? stateData.autoApprovalSettings
: prevState.autoApprovalSettings,
}
// Update welcome screen state based on API configuration if welcome view not in progress
if (!newState.welcomeViewCompleted && !showWelcomeRef.current) {
setShowWelcome(true)
setOnboardingModels(newState.onboardingModels)
} else if (newState.welcomeViewCompleted) {
setShowWelcome(false)
setOnboardingModels(undefined)
}
didHydrateStateRef.current = true
setDidHydrateState(true)
setWebviewBootstrapStatus("hydrated")
setWebviewBootstrapError(undefined)
clearBootstrapTimers()
return newState
})
} catch (error) {
console.error("Error parsing state JSON:", error)
console.log("[DEBUG] ERR getting state", error)
if (!didHydrateStateRef.current) {
setWebviewBootstrapStatus("degraded")
setWebviewBootstrapError(
`Received invalid initial state payload on attempt ${nextAttempt}. ${error instanceof Error ? error.message : String(error)}`,
)
scheduleRetry("parse-error")
}
}
}
console.log('[DEBUG] ended "got subscribed state"')
},
onError: (error) => {
console.error("Error in state subscription:", error)
if (!didHydrateStateRef.current) {
setWebviewBootstrapStatus("degraded")
setWebviewBootstrapError(
`State subscription failed before the webview finished loading. ${error instanceof Error ? error.message : String(error)}`,
)
scheduleRetry("state-subscription-error")
}
},
onComplete: () => {
console.log("State subscription completed")
},
})
UiServiceClient.initializeWebview(EmptyRequest.create({}))
.then(() => {
console.log("[DEBUG] Webview initialization completed via gRPC")
})
.catch((error) => {
console.error("Failed to initialize webview via gRPC:", error)
if (!didHydrateStateRef.current) {
setWebviewBootstrapStatus("degraded")
setWebviewBootstrapError(
`Webview initialization failed before the first state arrived. ${error instanceof Error ? error.message : String(error)}`,
)
scheduleRetry("initialize-webview-error")
}
})
hydrationTimeoutRef.current = setTimeout(() => {
if (!didHydrateStateRef.current) {
const timeoutMessage = `Timed out waiting for the initial Cline state after ${HYDRATION_TIMEOUT_MS / 1000} seconds (attempt ${nextAttempt}).`
console.error(`[WEBVIEW] ${timeoutMessage}`)
setWebviewBootstrapStatus("degraded")
setWebviewBootstrapError(timeoutMessage)
scheduleRetry("hydration-timeout")
}
}, HYDRATION_TIMEOUT_MS)
},
[clearBootstrapTimers],
)
const retryWebviewBootstrap = useCallback(() => {
startStateHydration("manual-retry")
}, [startStateHydration])
// Create hook function
const onRelinquishControl = useCallback((callback: () => void) => {
relinquishControlCallbacks.current.add(callback)
@@ -352,58 +533,7 @@ export const ExtensionStateContextProvider: React.FC<{
// Subscribe to state updates and UI events using the gRPC streaming API
useEffect(() => {
// Set up state subscription
stateSubscriptionRef.current = StateServiceClient.subscribeToState(EmptyRequest.create({}), {
onResponse: (response) => {
if (response.stateJson) {
try {
const stateData = JSON.parse(response.stateJson) as ExtensionState
setState((prevState) => {
// Versioning logic for autoApprovalSettings
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
const currentVersion = prevState.autoApprovalSettings?.version ?? 1
const shouldUpdateAutoApproval = incomingVersion > currentVersion
// HACK: Preserve clineMessages if currentTaskItem is the same
if (stateData.currentTaskItem?.id === prevState.currentTaskItem?.id) {
stateData.clineMessages = stateData.clineMessages?.length
? stateData.clineMessages
: prevState.clineMessages
}
const newState = {
...stateData,
autoApprovalSettings: shouldUpdateAutoApproval
? stateData.autoApprovalSettings
: prevState.autoApprovalSettings,
}
// Update welcome screen state based on API configuration if welcome view not in progress
if (!newState.welcomeViewCompleted && !showWelcome) {
setShowWelcome(true)
setOnboardingModels(newState.onboardingModels)
} else if (newState.welcomeViewCompleted) {
setShowWelcome(false)
setOnboardingModels(undefined)
}
setDidHydrateState(true)
return newState
})
} catch (error) {
console.error("Error parsing state JSON:", error)
console.log("[DEBUG] ERR getting state", error)
}
}
console.log('[DEBUG] ended "got subscribed state"')
},
onError: (error) => {
console.error("Error in state subscription:", error)
},
onComplete: () => {
console.log("State subscription completed")
},
})
startStateHydration("initial")
// Subscribe to MCP button clicked events with webview type
mcpButtonUnsubscribeRef.current = UiServiceClient.subscribeToMcpButtonClicked(
@@ -581,15 +711,6 @@ export const ExtensionStateContextProvider: React.FC<{
},
})
// Initialize webview using gRPC
UiServiceClient.initializeWebview(EmptyRequest.create({}))
.then(() => {
console.log("[DEBUG] Webview initialization completed via gRPC")
})
.catch((error) => {
console.error("Failed to initialize webview via gRPC:", error)
})
// Set up account button clicked subscription
accountButtonClickedSubscriptionRef.current = UiServiceClient.subscribeToAccountButtonClicked(EmptyRequest.create(), {
onResponse: () => {
@@ -630,6 +751,7 @@ export const ExtensionStateContextProvider: React.FC<{
// Clean up subscriptions when component unmounts
return () => {
clearBootstrapTimers()
if (stateSubscriptionRef.current) {
stateSubscriptionRef.current()
stateSubscriptionRef.current = null
@@ -687,7 +809,7 @@ export const ExtensionStateContextProvider: React.FC<{
mcpServersSubscriptionRef.current = null
}
}
}, [])
}, [clearBootstrapTimers, startStateHydration])
const refreshOpenRouterModels = useCallback(() => {
ModelsServiceClient.refreshOpenRouterModelsRpc(EmptyRequest.create({}))
@@ -786,6 +908,9 @@ export const ExtensionStateContextProvider: React.FC<{
const contextValue: ExtensionStateContextType = {
...state,
didHydrateState,
webviewBootstrapStatus,
webviewBootstrapError,
webviewBootstrapAttempt,
showWelcome,
onboardingModels,
clineModels,
@@ -917,6 +1042,8 @@ export const ExtensionStateContextProvider: React.FC<{
refreshHicapModels,
refreshLiteLlmModels,
onRelinquishControl,
retryWebviewBootstrap,
reloadWebview,
setUserInfo: (userInfo?: UserInfo) => setState((prevState) => ({ ...prevState, userInfo })),
expandTaskHeader,
setExpandTaskHeader,
+4 -1
View File
@@ -3,9 +3,12 @@ import { createRoot } from "react-dom/client"
import "./main.css"
import "./index.css"
import App from "./App.tsx"
import WebviewErrorBoundary from "./components/common/WebviewErrorBoundary"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<WebviewErrorBoundary>
<App />
</WebviewErrorBoundary>
</StrictMode>,
)