mirror of
https://github.com/cline/cline.git
synced 2026-09-09 06:45:53 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
917eeba5f6 | ||
|
|
410369618b |
Binary file not shown.
@@ -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)
|
||||
})
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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})`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user