feat(ui): add project git status panel

This commit is contained in:
JOJO 2026-05-31 17:27:24 +08:00
parent 6d5630c820
commit 6f78277a03
21 changed files with 2108 additions and 37 deletions

View File

@ -91,9 +91,11 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"deep_compress_behavior": "continue", # 手动压缩行为continue-注入并触发请求 / wait-仅插入等待用户
"silent_tool_disable": True, # 禁用工具时不向模型插入提示(默认开启)
"enhanced_tool_display": True, # 增强工具显示
"show_git_status_bar": True, # 是否显示输入栏上方 Git 状态栏
"versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
"allow_root_file_creation": False, # 允许在根目录创建文件开关
"default_hide_workspace": False, # 默认隐藏工作区
"theme": "classic", # 主题配色: classic-经典/light-明亮/dark-暗黑
# 目标模式Goal Mode
"goal_review_mode": "readonly", # readonly-仅读对话判断 / active-允许审核智能体跑只读命令取证
@ -412,6 +414,12 @@ def sanitize_personalization_payload(
else:
base["enhanced_tool_display"] = bool(base.get("enhanced_tool_display", True))
# Git 状态栏显示开关
if "show_git_status_bar" in data:
base["show_git_status_bar"] = bool(data.get("show_git_status_bar"))
else:
base["show_git_status_bar"] = bool(base.get("show_git_status_bar", True))
# 使用自定义称呼
if "use_custom_names" in data:
base["use_custom_names"] = bool(data.get("use_custom_names"))
@ -430,6 +438,12 @@ def sanitize_personalization_payload(
else:
base["allow_root_file_creation"] = bool(base.get("allow_root_file_creation", False))
# 默认隐藏工作区
if "default_hide_workspace" in data:
base["default_hide_workspace"] = bool(data.get("default_hide_workspace"))
else:
base["default_hide_workspace"] = bool(base.get("default_hide_workspace", False))
# 主题配色
theme_value = data.get("theme", base.get("theme"))
if isinstance(theme_value, str) and theme_value in ALLOWED_THEMES:

View File

@ -2,6 +2,12 @@ from __future__ import annotations
import time
import re
import os
import json
import shutil
import subprocess
import sys
import tempfile
import plistlib
from pathlib import Path
from flask import Blueprint, jsonify, request, send_file, session
@ -128,6 +134,546 @@ def _active_task_counts(username: str) -> dict:
return running_by_workspace
def _run_project_git(project_path: Path, args: list[str]) -> tuple[bool, str]:
git_bin = shutil.which("git")
if not git_bin:
return False, ""
try:
proc = subprocess.run(
[git_bin, "-C", str(project_path), *args],
cwd=str(project_path),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=2,
check=False,
)
except Exception:
return False, ""
return proc.returncode == 0, (proc.stdout or "").strip()
def _run_project_git_raw(project_path: Path, args: list[str], timeout: int = 4) -> tuple[bool, str]:
git_bin = shutil.which("git")
if not git_bin:
return False, ""
try:
proc = subprocess.run(
[git_bin, "-C", str(project_path), *args],
cwd=str(project_path),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
timeout=timeout,
check=False,
)
except Exception:
return False, ""
return proc.returncode == 0, (proc.stdout or "")
def _sum_git_numstat(numstat_text: str) -> tuple[int, int]:
additions = 0
deletions = 0
for raw_line in (numstat_text or "").splitlines():
parts = raw_line.split("\t", 2)
if len(parts) < 2:
continue
if parts[0].isdigit():
additions += int(parts[0])
if parts[1].isdigit():
deletions += int(parts[1])
return additions, deletions
def _paths_from_git_numstat(numstat_text: str) -> list[str]:
paths: list[str] = []
for raw_line in (numstat_text or "").splitlines():
parts = raw_line.split("\t")
if len(parts) < 3:
continue
path = parts[-1].strip()
if path:
paths.append(path)
return paths
def _count_text_lines(text: str) -> int:
if text == "":
return 0
return len(text.splitlines())
def _git_file_old_line_count(repo_root: Path, rel_path: str) -> int:
ok, text = _run_project_git_raw(repo_root, ["show", f"HEAD:{rel_path}"], timeout=3)
if ok:
return _count_text_lines(text)
target = (repo_root / rel_path).resolve()
try:
if target.exists() and repo_root in target.parents:
return _count_text_lines(target.read_text(encoding="utf-8", errors="ignore"))
except Exception:
pass
return 0
def _git_file_lines(repo_root: Path, rel_path: str, ref: str = "HEAD") -> list[str]:
ok, text = _run_project_git_raw(repo_root, ["show", f"{ref}:{rel_path}"], timeout=3)
if ok:
return text.splitlines()
return []
def _working_file_lines(repo_root: Path, rel_path: str) -> list[str]:
target = (repo_root / rel_path).resolve()
try:
if target.exists() and repo_root in target.parents:
return target.read_text(encoding="utf-8", errors="ignore").splitlines()
except Exception:
pass
return _git_file_lines(repo_root, rel_path)
def _parse_hunk_header(header: str) -> tuple[int, int, int, int]:
match = re.match(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?", header)
if not match:
return 0, 0, 0, 0
old_start = int(match.group(1))
old_count = int(match.group(2) or "1")
new_start = int(match.group(3))
new_count = int(match.group(4) or "1")
return old_start, old_count, new_start, new_count
def _parse_git_diff(repo_root: Path, diff_text: str, fold_context_map: dict[str, dict[str, int]] | None = None) -> list[dict]:
files: list[dict] = []
current: dict | None = None
current_hunk: dict | None = None
old_line = 0
new_line = 0
previous_old_end = 0
previous_new_end = 0
def finish_hunk():
nonlocal current_hunk, current, previous_old_end, previous_new_end
if not current_hunk or not current:
current_hunk = None
return
path = current.get("path") or current.get("old_path") or ""
old_start = current_hunk.get("old_start") or 0
old_count = current_hunk.get("old_count") or 0
new_start = current_hunk.get("new_start") or 0
new_count = current_hunk.get("new_count") or 0
current_hunk["before_hidden"] = max(0, int(old_start) - int(previous_old_end) - 1)
before_fold_key = f"before:{old_start}"
current_hunk["before_fold_key"] = before_fold_key
requested_extra = int((fold_context_map or {}).get(path, {}).get(before_fold_key, 0) or 0)
if requested_extra > 0 and current_hunk["before_hidden"] > 0:
add_count = min(current_hunk["before_hidden"], requested_extra)
old_lines = _git_file_lines(repo_root, current.get("old_path") or path)
new_lines = _working_file_lines(repo_root, path)
injected = []
for idx in range(add_count):
old_no = int(old_start) - add_count + idx
new_no = int(new_start) - add_count + idx
text = ""
if 0 < new_no <= len(new_lines):
text = new_lines[new_no - 1]
elif 0 < old_no <= len(old_lines):
text = old_lines[old_no - 1]
injected.append({"kind": "context", "old_line": old_no, "new_line": new_no, "text": text})
current_hunk["lines"] = injected + current_hunk.get("lines", [])
current_hunk["before_hidden"] -= add_count
previous_old_end = int(old_start) + int(old_count) - 1
previous_new_end = int(new_start) + int(new_count) - 1
current["hunks"].append(current_hunk)
current_hunk = None
def finish_file():
nonlocal current, previous_old_end, previous_new_end
finish_hunk()
if not current:
return
old_total = _git_file_old_line_count(repo_root, current.get("old_path") or current.get("path") or "")
current["after_hidden"] = max(0, old_total - int(previous_old_end))
current["after_fold_key"] = "after"
path = current.get("path") or current.get("old_path") or ""
requested_extra = int((fold_context_map or {}).get(path, {}).get("after", 0) or 0)
if requested_extra > 0 and current["after_hidden"] > 0 and current.get("hunks"):
add_count = min(current["after_hidden"], requested_extra)
old_lines = _git_file_lines(repo_root, current.get("old_path") or path)
new_lines = _working_file_lines(repo_root, path)
injected = []
for idx in range(add_count):
old_no = int(previous_old_end) + 1 + idx
new_no = int(previous_new_end) + 1 + idx
text = ""
if 0 < new_no <= len(new_lines):
text = new_lines[new_no - 1]
elif 0 < old_no <= len(old_lines):
text = old_lines[old_no - 1]
injected.append({"kind": "context", "old_line": old_no, "new_line": new_no, "text": text})
current["hunks"][-1]["lines"].extend(injected)
current["after_hidden"] -= add_count
files.append(current)
current = None
previous_old_end = 0
previous_new_end = 0
for raw in diff_text.splitlines():
if raw.startswith("diff --git "):
finish_file()
current = {
"path": "",
"old_path": "",
"additions": 0,
"deletions": 0,
"hunks": [],
"after_hidden": 0,
}
previous_old_end = 0
continue
if current is None:
continue
if raw.startswith("--- "):
old_path = raw[4:].strip()
current["old_path"] = "" if old_path == "/dev/null" else re.sub(r"^a/", "", old_path)
continue
if raw.startswith("+++ "):
new_path = raw[4:].strip()
current["path"] = current["old_path"] if new_path == "/dev/null" else re.sub(r"^b/", "", new_path)
continue
if raw.startswith("@@ "):
finish_hunk()
old_start, old_count, new_start, new_count = _parse_hunk_header(raw)
current_hunk = {
"header": raw,
"old_start": old_start,
"old_count": old_count,
"new_start": new_start,
"new_count": new_count,
"before_hidden": 0,
"lines": [],
}
old_line = old_start
new_line = new_start
continue
if current_hunk is None:
continue
marker = raw[:1]
if marker not in {" ", "+", "-"}:
continue
text = raw[1:]
line = {"kind": "context", "old_line": old_line, "new_line": new_line, "text": text}
if marker == "+":
line = {"kind": "add", "old_line": None, "new_line": new_line, "text": text}
new_line += 1
current["additions"] += 1
elif marker == "-":
line = {"kind": "delete", "old_line": old_line, "new_line": None, "text": text}
old_line += 1
current["deletions"] += 1
else:
old_line += 1
new_line += 1
current_hunk["lines"].append(line)
finish_file()
return [item for item in files if item.get("path") and item.get("hunks")]
def _open_path_in_file_manager(path: Path) -> bool:
target = path.expanduser().resolve()
if not target.exists():
return False
try:
if sys.platform == "darwin":
subprocess.Popen(["open", str(target)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
if os.name == "nt":
os.startfile(str(target)) # type: ignore[attr-defined]
return True
opener = shutil.which("xdg-open")
if opener:
subprocess.Popen([opener, str(target)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
return False
return False
def _resolve_project_git_file(workspace, rel_path: str) -> tuple[Path | None, str]:
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
repo_root = Path(root_text).expanduser().resolve() if ok and root_text else project_path
try:
target = (repo_root / rel_path).expanduser().resolve()
target.relative_to(repo_root)
except Exception:
return None, "文件路径无效"
if not target.exists() or not target.is_file():
return None, "文件不存在"
return target, ""
def _mac_open_app_candidates(file_path: Path | None = None) -> list[dict]:
app_paths: list[Path] = []
default_app_path = ""
if file_path is not None:
try:
from AppKit import NSWorkspace
from Foundation import NSURL
file_url = NSURL.fileURLWithPath_(str(file_path))
workspace = NSWorkspace.sharedWorkspace()
urls = workspace.URLsForApplicationsToOpenURL_(file_url) or []
app_paths.extend(Path(str(url.path())) for url in urls if url and url.path())
default_url = workspace.URLForApplicationToOpenURL_(file_url)
if default_url and default_url.path():
default_app_path = str(Path(str(default_url.path())).expanduser().absolute())
except Exception:
app_paths = []
if not app_paths:
# 只在 LaunchServices 不可用时做很小的兜底;正常路径必须使用系统针对该文件的结果。
for base in ["/System/Applications/TextEdit.app", "/Applications/Xcode.app", "/Applications/Cursor.app"]:
path = Path(base).expanduser()
if path.exists():
app_paths.append(path)
seen = set()
apps: list[dict] = []
for path in app_paths:
try:
visible_path = path.expanduser().absolute()
except Exception:
continue
path_text = str(visible_path)
if (
path_text in seen
or "__pycache__" in path_text
or "/Library/Application Support/" in path_text
or "/System/Library/PrivateFrameworks/" in path_text
or "/System/Library/CoreServices/" in path_text
or "/System/Library/Services/" in path_text
):
continue
info = _read_mac_app_info(visible_path)
label = info.get("label") or visible_path.stem
bundle_id = str(info.get("bundle_id") or "")
if bundle_id == "com.microsoft.VSCode" or label == "Code":
label = visible_path.stem
label_lower = label.lower()
if label_lower.endswith("agent") or label_lower in {"finder", "system settings"}:
continue
seen.add(path_text)
apps.append({
"id": path_text,
"label": f"{label}(默认)" if default_app_path and path_text == default_app_path else label,
"bundle_id": bundle_id,
"icon_url": f"/api/project/app-icon?app_id={path_text}",
"rank": 0 if default_app_path and path_text == default_app_path else 1,
})
apps.sort(key=lambda item: (item.get("rank", 1000), item.get("label", "").lower()))
return [{k: v for k, v in item.items() if k != "rank"} for item in apps[:40]]
def _read_mac_app_info(app_path: Path) -> dict:
info_path = app_path / "Contents" / "Info.plist"
try:
data = plistlib.loads(info_path.read_bytes())
except Exception:
data = {}
return {
"label": data.get("CFBundleDisplayName") or data.get("CFBundleName") or app_path.stem,
"bundle_id": data.get("CFBundleIdentifier") or "",
"icon_file": data.get("CFBundleIconFile") or "",
}
def _mac_app_icon_path(app_path: Path) -> Path | None:
info = _read_mac_app_info(app_path)
icon_name = str(info.get("icon_file") or "").strip()
resources = app_path / "Contents" / "Resources"
candidates = []
if icon_name:
candidates.append(resources / icon_name)
if not icon_name.endswith(".icns"):
candidates.append(resources / f"{icon_name}.icns")
candidates.extend(resources.glob("*.icns"))
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _windows_open_app_candidates(file_path: Path | None = None) -> list[dict]:
if os.name != "nt" or file_path is None:
return []
try:
import ctypes
import shlex
import winreg
except Exception:
return []
ext = file_path.suffix.lower()
if not ext:
return []
def expand_env(value: str) -> str:
try:
return os.path.expandvars(value)
except Exception:
return value
def parse_command_exe(command: str) -> str:
command = expand_env((command or "").strip())
if not command:
return ""
try:
parts = shlex.split(command, posix=False)
if parts:
return parts[0].strip('"')
except Exception:
pass
if command.startswith('"'):
end = command.find('"', 1)
return command[1:end] if end > 1 else ""
return command.split(" ", 1)[0].strip('"')
def read_reg_value(root, subkey: str, value_name: str = "") -> str:
try:
with winreg.OpenKey(root, subkey) as key:
value, _ = winreg.QueryValueEx(key, value_name)
return str(value or "")
except Exception:
return ""
def enum_subkey_values(root, subkey: str) -> list[str]:
values: list[str] = []
try:
with winreg.OpenKey(root, subkey) as key:
idx = 0
while True:
try:
name, value, _ = winreg.EnumValue(key, idx)
idx += 1
if name and name != "MRUList":
values.append(str(value or name))
except OSError:
break
except Exception:
pass
return values
def command_for_progid(progid: str) -> str:
for root in (winreg.HKEY_CURRENT_USER, winreg.HKEY_CLASSES_ROOT, winreg.HKEY_LOCAL_MACHINE):
for prefix in ("Software\\Classes\\", ""):
command = read_reg_value(root, f"{prefix}{progid}\\shell\\open\\command")
if command:
return command
return ""
def app_label(exe: Path, default: bool = False) -> str:
stem = exe.stem
label_map = {
"notepad": "记事本",
"code": "Visual Studio Code",
"cursor": "Cursor",
"wordpad": "写字板",
}
label = label_map.get(stem.lower(), stem)
return f"{label}(默认)" if default else label
candidates: list[tuple[str, bool]] = []
# 默认打开程序Windows Shell 关联查询,等价于系统“打开方式”的默认项。
try:
ASSOCF_NONE = 0
ASSOCSTR_EXECUTABLE = 2
buffer_len = ctypes.c_ulong(0)
ctypes.windll.Shlwapi.AssocQueryStringW(
ASSOCF_NONE, ASSOCSTR_EXECUTABLE, ext, None, None, ctypes.byref(buffer_len)
)
if buffer_len.value > 0:
buffer = ctypes.create_unicode_buffer(buffer_len.value)
result = ctypes.windll.Shlwapi.AssocQueryStringW(
ASSOCF_NONE, ASSOCSTR_EXECUTABLE, ext, None, buffer, ctypes.byref(buffer_len)
)
if result == 0 and buffer.value:
candidates.append((buffer.value, True))
except Exception:
pass
progids = set()
for root, subkey in (
(winreg.HKEY_CURRENT_USER, f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\OpenWithProgids"),
(winreg.HKEY_CLASSES_ROOT, f"{ext}\\OpenWithProgids"),
):
progids.update(enum_subkey_values(root, subkey))
default_progid = read_reg_value(winreg.HKEY_CLASSES_ROOT, ext)
if default_progid:
progids.add(default_progid)
user_choice = read_reg_value(
winreg.HKEY_CURRENT_USER,
f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\UserChoice",
"ProgId",
)
if user_choice:
progids.add(user_choice)
for progid in progids:
command = command_for_progid(progid)
exe = parse_command_exe(command)
if exe:
candidates.append((exe, False))
app_names = enum_subkey_values(
winreg.HKEY_CURRENT_USER,
f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\OpenWithList",
)
app_names += enum_subkey_values(
winreg.HKEY_CLASSES_ROOT,
f"{ext}\\OpenWithList",
)
for app_name in app_names:
app_key = f"Applications\\{app_name}\\shell\\open\\command"
command = read_reg_value(winreg.HKEY_CLASSES_ROOT, app_key)
exe = parse_command_exe(command)
if exe:
candidates.append((exe, False))
seen = set()
apps = []
for exe, is_default in candidates:
try:
path = Path(expand_env(exe)).expanduser().resolve()
except Exception:
continue
key = str(path).lower()
if key in seen or not path.exists() or path.suffix.lower() != ".exe":
continue
seen.add(key)
apps.append({"id": str(path), "label": app_label(path, is_default)})
return apps[:40]
def _open_file_with_app(file_path: Path, app_id: str) -> bool:
try:
if sys.platform == "darwin":
app_path = Path(app_id).expanduser().resolve()
if not app_path.exists() or app_path.suffix != ".app":
return False
subprocess.Popen(["open", "-a", str(app_path), str(file_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
if os.name == "nt":
exe_path = Path(app_id).expanduser().resolve()
if not exe_path.exists() or exe_path.suffix.lower() != ".exe":
return False
subprocess.Popen([str(exe_path), str(file_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True
except Exception:
return False
return False
@status_bp.route('/api/health')
@api_login_required
def get_health():
@ -248,6 +794,229 @@ def get_status(terminal, workspace, username):
return jsonify(status)
@status_bp.route('/api/project/git-summary')
@api_login_required
@with_terminal
def get_project_git_summary(terminal, workspace, username):
"""返回当前项目自身 git 仓库概览(不使用隐藏版本管理 git"""
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
if not project_path.exists():
return jsonify({"success": True, "data": {"has_git": False}})
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
if not ok or not root_text:
return jsonify({"success": True, "data": {"has_git": False}})
repo_root = Path(root_text).expanduser().resolve()
ok, branch = _run_project_git(repo_root, ["branch", "--show-current"])
if not ok or not branch:
ok, branch = _run_project_git(repo_root, ["rev-parse", "--short", "HEAD"])
branch = f"HEAD {branch}" if ok and branch else "HEAD"
_, upstream = _run_project_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
_, numstat_text = _run_project_git(repo_root, ["diff", "--numstat", "HEAD", "--"])
additions, deletions = _sum_git_numstat(numstat_text)
return jsonify({
"success": True,
"data": {
"has_git": True,
"project_name": repo_root.name,
"project_path": str(repo_root),
"branch": branch,
"upstream": upstream,
"additions": additions,
"deletions": deletions,
}
})
@status_bp.route('/api/project/git-diff')
@api_login_required
@with_terminal
def get_project_git_diff(terminal, workspace, username):
"""返回当前项目自身 git 工作区 diff用于前端侧边栏展示。"""
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
if not project_path.exists():
return jsonify({"success": True, "data": {"has_git": False, "files": []}})
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
if not ok or not root_text:
return jsonify({"success": True, "data": {"has_git": False, "files": []}})
repo_root = Path(root_text).expanduser().resolve()
try:
context_lines = int(request.args.get("context", "3") or 3)
except Exception:
context_lines = 3
context_lines = max(0, min(200, context_lines))
context_map: dict[str, int] = {}
raw_context_map = request.args.get("context_map", "").strip()
if raw_context_map:
try:
parsed = json.loads(raw_context_map)
if isinstance(parsed, dict):
for key, value in parsed.items():
if not isinstance(key, str) or not key:
continue
try:
context_map[key] = max(0, min(200, int(value)))
except Exception:
continue
except Exception:
context_map = {}
fold_context_map: dict[str, dict[str, int]] = {}
raw_fold_context_map = request.args.get("fold_context_map", "").strip()
if raw_fold_context_map:
try:
parsed = json.loads(raw_fold_context_map)
if isinstance(parsed, dict):
for path_key, folds in parsed.items():
if not isinstance(path_key, str) or not isinstance(folds, dict):
continue
safe_folds: dict[str, int] = {}
for fold_key, value in folds.items():
if not isinstance(fold_key, str) or not fold_key:
continue
try:
safe_folds[fold_key] = max(0, min(200, int(value)))
except Exception:
continue
if safe_folds:
fold_context_map[path_key] = safe_folds
except Exception:
fold_context_map = {}
ok, branch = _run_project_git(repo_root, ["branch", "--show-current"])
if not ok or not branch:
ok, branch = _run_project_git(repo_root, ["rev-parse", "--short", "HEAD"])
branch = f"HEAD {branch}" if ok and branch else "HEAD"
_, upstream = _run_project_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
_, numstat_text = _run_project_git(repo_root, ["diff", "--numstat", "HEAD", "--"])
additions, deletions = _sum_git_numstat(numstat_text)
diff_text = ""
if context_map:
parts: list[str] = []
for rel_path in _paths_from_git_numstat(numstat_text):
per_file_context = context_map.get(rel_path, context_lines)
ok, file_diff = _run_project_git_raw(
repo_root,
["--no-pager", "diff", f"--unified={per_file_context}", "HEAD", "--", rel_path],
timeout=8,
)
if ok and file_diff:
parts.append(file_diff)
diff_text = "\n".join(parts)
else:
ok, diff_text = _run_project_git_raw(repo_root, [f"--no-pager", "diff", f"--unified={context_lines}", "HEAD", "--"], timeout=8)
if not ok:
diff_text = ""
return jsonify({
"success": True,
"data": {
"has_git": True,
"project_name": repo_root.name,
"project_path": str(repo_root),
"branch": branch,
"upstream": upstream,
"additions": additions,
"deletions": deletions,
"context": context_lines,
"files": _parse_git_diff(repo_root, diff_text, fold_context_map=fold_context_map),
}
})
@status_bp.route('/api/project/open-in-file-manager', methods=['POST'])
@api_login_required
@with_terminal
def open_project_in_file_manager(terminal, workspace, username):
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
target = Path(root_text).expanduser().resolve() if ok and root_text else project_path
if _open_path_in_file_manager(target):
return jsonify({"success": True})
return jsonify({"success": False, "error": "无法打开文件管理器"}), 500
@status_bp.route('/api/project/file-open-apps')
@api_login_required
@with_terminal
def list_project_file_open_apps(terminal, workspace, username):
if not _is_host_mode_request():
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
rel_path = request.args.get("path", "").strip()
target, error = _resolve_project_git_file(workspace, rel_path)
if not target:
return jsonify({"success": False, "error": error or "文件路径无效"}), 400
if sys.platform == "darwin":
apps = _mac_open_app_candidates(target)
elif os.name == "nt":
apps = _windows_open_app_candidates(target)
else:
apps = []
return jsonify({"success": True, "data": {"apps": apps}})
@status_bp.route('/api/project/app-icon')
@api_login_required
def get_project_open_app_icon():
if not _is_host_mode_request():
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
app_id = request.args.get("app_id", "").strip()
if sys.platform != "darwin" or not app_id:
return jsonify({"success": False, "error": "应用图标不可用"}), 404
try:
app_path = Path(app_id).expanduser().resolve()
if not app_path.exists() or app_path.suffix != ".app":
return jsonify({"success": False, "error": "应用不存在"}), 404
icon_path = _mac_app_icon_path(app_path)
if not icon_path:
return jsonify({"success": False, "error": "应用图标不存在"}), 404
cache_dir = Path(tempfile.gettempdir()) / "agents_app_icons"
cache_dir.mkdir(parents=True, exist_ok=True)
out_path = cache_dir / f"{abs(hash(str(app_path)))}.png"
if not out_path.exists() or out_path.stat().st_mtime < icon_path.stat().st_mtime:
sips = shutil.which("sips")
if not sips:
return jsonify({"success": False, "error": "图标转换工具不可用"}), 404
subprocess.run(
[sips, "-s", "format", "png", str(icon_path), "--out", str(out_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=5,
check=False,
)
if out_path.exists():
return send_file(str(out_path), mimetype="image/png")
except Exception:
pass
return jsonify({"success": False, "error": "应用图标不可用"}), 404
@status_bp.route('/api/project/open-file-with-app', methods=['POST'])
@api_login_required
@with_terminal
def open_project_file_with_app(terminal, workspace, username):
if not _is_host_mode_request():
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
payload = request.get_json(silent=True) or {}
rel_path = str(payload.get("path") or "").strip()
app_id = str(payload.get("app_id") or "").strip()
if not app_id:
return jsonify({"success": False, "error": "未选择应用"}), 400
target, error = _resolve_project_git_file(workspace, rel_path)
if not target:
return jsonify({"success": False, "error": error or "文件路径无效"}), 400
available = _mac_open_app_candidates(target) if sys.platform == "darwin" else _windows_open_app_candidates(target) if os.name == "nt" else []
if not any(item.get("id") == app_id for item in available):
return jsonify({"success": False, "error": "应用不可用"}), 400
if _open_file_with_app(target, app_id):
return jsonify({"success": True})
return jsonify({"success": False, "error": "打开文件失败"}), 500
@status_bp.route('/api/host/workspaces')
@api_login_required
def list_host_workspaces():

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12.5 22H18a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v9.5"/><path d="M14 2v4a2 2 0 0 0 2 2h4m-6.622 7.626a1 1 0 1 0-3.004-3.004l-5.01 5.012a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506z"/></svg>

After

Width:  |  Height:  |  Size: 426 B

View File

@ -304,6 +304,7 @@
:current-context-tokens="currentContextTokens"
:versioning-enabled="versioningEnabled"
:runtime-queued-messages="runtimeQueuedMessages"
:project-git-summary="projectGitSummary"
:user-question-minimized="userQuestionMinimized"
:pending-user-question-count="pendingUserQuestions.length"
:goal-mode-armed="goalModeArmed"
@ -346,11 +347,31 @@
@composer-height-change="handleComposerHeightChange"
@toggle-goal-mode="handleToggleGoalMode"
@open-goal-dialog="goalDialogOpen = true"
@open-git-changes-panel="openGitChangesPanel"
/>
</div>
</main>
<div
v-if="!isMobileViewport && rightCollapsed"
v-if="!isMobileViewport && gitChangesPanelOpen"
class="resize-handle resize-handle--git-changes"
@mousedown="startResize('right', $event)"
></div>
<transition name="git-changes-panel-slide">
<GitChangesPanel
v-if="!isMobileViewport && gitChangesPanelOpen"
:width="Math.min(rightWidth || 420, 600)"
:loading="gitChangesLoading"
:error="gitChangesError"
:diff="gitChangesDiff"
:fold-contexts="gitChangesFoldContexts"
:host-mode="versioningHostMode"
@close="closeGitChangesPanel"
@expand-context="expandGitChangesContext"
@reset-context="resetGitChangesContext"
/>
</transition>
<div
v-if="!isMobileViewport && rightCollapsed && !gitChangesPanelOpen"
class="resize-handle"
@mousedown="startResize('right', $event)"
></div>

View File

@ -3,6 +3,7 @@ import ChatArea from '../components/chat/ChatArea.vue';
import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue';
import LeftPanel from '../components/panels/LeftPanel.vue';
import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue';
import GitChangesPanel from '../components/panels/GitChangesPanel.vue';
import TokenDrawer from '../components/token/TokenDrawer.vue';
import QuickMenu from '../components/input/QuickMenu.vue';
import InputComposer from '../components/input/InputComposer.vue';
@ -48,6 +49,7 @@ export const appComponents = {
ConversationSidebar,
LeftPanel,
ToolApprovalPanel,
GitChangesPanel,
TokenDrawer,
PersonalizationDrawer,
QuickMenu,

View File

@ -41,6 +41,7 @@ export async function mounted() {
// 立即加载初始数据(并行获取状态,优先同步运行模式)
const initialDataPromise = this.loadInitialData();
this.refreshProjectGitSummary?.();
this.startConnectionHeartbeat();
this.checkTutorialPrompt();
if (initialDataPromise && typeof initialDataPromise.then === 'function') {

View File

@ -130,6 +130,10 @@ export const conversationMethods = {
this._autoRelockCooldownUntil = 0;
this.$nextTick(() => {
this.ensureScrollListener();
const composerRef = typeof this.getInputComposerRef === 'function' ? this.getInputComposerRef() : null;
if (composerRef && typeof composerRef.emitComposerHeight === 'function') {
composerRef.emitComposerHeight();
}
});
// 重置已加载对话标记,便于后续重新加载新对话历史
@ -334,6 +338,7 @@ export const conversationMethods = {
// 2. 更新当前对话信息
this.skipConversationHistoryReload = true;
this.currentConversationId = conversationId;
this.refreshProjectGitSummary?.();
this.currentConversationTitle = result.title;
this.titleReady = true;
this.suppressTitleTyping = false;

View File

@ -1299,6 +1299,10 @@ export const taskPollingMethods = {
if (data.status) {
targetAction.tool.status = data.status;
const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled', 'canceled']);
if (terminalStatuses.has(String(data.status))) {
this.refreshProjectGitSummary?.();
}
}
if (data.result !== undefined) {
targetAction.tool.result = data.result;

View File

@ -154,6 +154,121 @@ function parseSystemNoticeLabel(rawContent: any): string | null {
}
export const uiMethods = {
async refreshProjectGitSummary() {
try {
const resp = await fetch('/api/project/git-summary');
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
this.projectGitSummary = null;
this.gitChangesDiff = null;
return;
}
this.projectGitSummary = payload.data || null;
if (this.gitChangesPanelOpen) {
this.loadGitChangesDiff?.();
}
} catch (_) {
this.projectGitSummary = null;
this.gitChangesDiff = null;
}
},
async loadGitChangesDiff() {
if (!this.gitChangesPanelOpen) return;
const hasExistingDiff = Boolean(this.gitChangesDiff?.has_git);
this.gitChangesLoading = true;
this.gitChangesError = '';
try {
const context = Math.max(0, Math.min(200, Number(this.gitChangesContext || 3)));
const foldContextMap =
this.gitChangesFoldContexts && typeof this.gitChangesFoldContexts === 'object'
? this.gitChangesFoldContexts
: {};
const params = new URLSearchParams();
params.set('context', String(context));
params.set('_', String(Date.now()));
if (Object.keys(foldContextMap).length > 0) {
params.set('fold_context_map', JSON.stringify(foldContextMap));
}
const resp = await fetch(
`/api/project/git-diff?${params.toString()}`,
{ cache: 'no-store' }
);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
this.gitChangesError = payload?.error || '加载 Git 变更失败';
if (!hasExistingDiff) {
this.gitChangesDiff = null;
}
return;
}
this.gitChangesDiff = payload.data || null;
if (!this.gitChangesDiff?.has_git) {
this.gitChangesPanelOpen = false;
}
} catch (error) {
this.gitChangesError = '加载 Git 变更失败';
if (!hasExistingDiff) {
this.gitChangesDiff = null;
}
} finally {
this.gitChangesLoading = false;
}
},
openGitChangesPanel() {
if (!this.projectGitSummary?.has_git) return;
this.gitChangesPanelOpen = !this.gitChangesPanelOpen;
if (this.gitChangesPanelOpen) {
this.gitChangesContext = 3;
this.gitChangesFileContexts = {};
this.gitChangesFoldContexts = {};
this.loadGitChangesDiff?.();
}
},
closeGitChangesPanel() {
this.gitChangesPanelOpen = false;
},
expandGitChangesContext(payload) {
const path = typeof payload === 'object' ? payload?.path : payload;
const foldKey = typeof payload === 'object' ? payload?.foldKey : '';
const filePath = String(path || '').trim();
const key = String(foldKey || '').trim();
if (!filePath || !key) return;
const currentMap =
this.gitChangesFoldContexts && typeof this.gitChangesFoldContexts === 'object'
? this.gitChangesFoldContexts
: {};
const fileMap = currentMap[filePath] && typeof currentMap[filePath] === 'object' ? currentMap[filePath] : {};
const current = Number(fileMap[key] || 0);
this.gitChangesFoldContexts = {
...currentMap,
[filePath]: {
...fileMap,
[key]: Math.min(200, (Number.isFinite(current) ? current : 0) + 20)
}
};
this.loadGitChangesDiff?.();
},
resetGitChangesContext(path) {
const filePath = String(path || '').trim();
if (!filePath) return;
const currentMap =
this.gitChangesFoldContexts && typeof this.gitChangesFoldContexts === 'object'
? this.gitChangesFoldContexts
: {};
if (!currentMap[filePath]) {
return;
}
const nextMap = { ...currentMap };
delete nextMap[filePath];
this.gitChangesFoldContexts = nextMap;
this.loadGitChangesDiff?.();
},
clearLocalTaskUiState(reason = 'safe-navigation') {
// 只清理当前浏览器视图里的运行态/工具态,不取消后端任务。
// 用于切换对话/工作区后,避免旧任务残留让新视图的发送按钮显示为“停止”。
@ -1084,6 +1199,7 @@ export const uiMethods = {
: (switchedLabel || targetId),
type: 'success'
});
this.refreshProjectGitSummary?.();
} catch (error) {
this.conversations = previousConversationList;
this.searchActive = previousSearchActive;

View File

@ -36,6 +36,14 @@ export function dataState() {
runtimeQueueAutoSendInProgress: false,
runtimeQueueSyncLockKey: '',
runtimeQueueSyncLockUntil: 0,
projectGitSummary: null,
gitChangesPanelOpen: false,
gitChangesLoading: false,
gitChangesError: '',
gitChangesContext: 3,
gitChangesFileContexts: {},
gitChangesFoldContexts: {},
gitChangesDiff: null,
// 输入区动态保留高度(用于同步扩大消息区可滚动范围)
composerReservedHeight: 80,
// 记录上一次成功加载历史的对话ID防止初始化阶段重复加载导致动画播放两次

View File

@ -592,6 +592,7 @@
</div>
</div>
</div>
<div class="messages-bottom-spacer" aria-hidden="true"></div>
</div>
</template>

View File

@ -1,24 +1,69 @@
<template>
<div class="input-area compact-input-area" ref="inputAreaRoot">
<div class="stadium-input-wrapper" ref="stadiumShellOuter">
<transition name="goal-banner-fade">
<transition name="floating-status-motion">
<div
v-if="goalModeArmed || goalRunning || goalCompleted"
class="goal-mode-banner"
:class="{
'goal-mode-banner--running': goalRunning,
'goal-mode-banner--done': goalCompleted && !goalRunning,
'goal-mode-banner--clickable': goalRunning || goalCompleted,
'goal-mode-banner--collapsed': goalBannerCollapsed
}"
:title="goalBannerCollapsed ? goalBannerTitle : undefined"
@click.stop="goalRunning || goalCompleted ? $emit('open-goal-dialog') : null"
v-if="floatingStatusVisible"
class="floating-project-status"
@click.stop
>
<span class="goal-mode-banner__dot" aria-hidden="true"></span>
<span class="goal-mode-banner__text">
<template v-if="goalRunning">目标模式运行中</template>
<template v-else-if="goalCompleted">目标模式完成</template>
<template v-else>目标模式已就绪</template>
<span v-if="projectGitSummaryForRender" class="floating-project-status__left">
<span class="floating-project-status__menu-wrap">
<button
type="button"
class="floating-project-status__button floating-project-status__button--project"
:class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'project' }"
@click.stop="toggleProjectGitMenu('project')"
>
{{ projectGitSummaryForRender.projectName }}
</button>
<div
v-if="projectGitMenuOpen === 'project'"
class="floating-project-status__submenu floating-project-status__submenu--project"
@click.stop
>
<button type="button" @click.stop="openProjectInFileManager">在文件管理器中打开</button>
<button type="button" @click.stop="copyProjectPath">复制地址</button>
</div>
</span>
<span class="floating-project-status__menu-wrap">
<button
type="button"
class="floating-project-status__button floating-project-status__button--branch"
:class="{ 'floating-project-status__button--active': projectGitMenuOpen === 'branch' }"
@click.stop="toggleProjectGitMenu('branch')"
>
{{ projectGitSummaryForRender.branch }}
</button>
<div
v-if="projectGitMenuOpen === 'branch'"
class="floating-project-status__submenu floating-project-status__submenu--branch"
@click.stop
>
<button type="button" @click.stop="copyProjectBranch">复制分支名称</button>
</div>
</span>
</span>
<span class="floating-project-status__right">
<span v-if="goalRunning" class="floating-project-status__notice">目标模式运行中</span>
<span v-else-if="goalModeArmed" class="floating-project-status__notice">目标模式就绪</span>
<button
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
type="button"
class="floating-project-status__notice floating-project-status__notice--button"
@click.stop="$emit('restore-user-question')"
>
等待回答问题
</button>
<button
v-if="projectGitSummaryForRender"
type="button"
class="floating-project-status__stats"
@click.stop="$emit('open-git-changes-panel')"
>
<span class="floating-project-status__add">+{{ projectGitSummaryForRender.additions }}</span>
<span class="floating-project-status__del">-{{ projectGitSummaryForRender.deletions }}</span>
</button>
</span>
</div>
</transition>
@ -167,15 +212,6 @@
<span v-if="showStopIcon" class="stop-icon"></span>
<span v-else class="send-icon"></span>
</button>
<button
v-if="userQuestionMinimized && pendingUserQuestionCount > 0"
type="button"
class="user-question-mini-dot"
aria-label="打开待回答问题"
@click.stop="$emit('restore-user-question')"
>
{{ pendingUserQuestionCount }}
</button>
</div>
</div>
</div>
@ -372,7 +408,8 @@ const emit = defineEmits([
'composer-height-change',
'restore-user-question',
'toggle-goal-mode',
'open-goal-dialog'
'open-goal-dialog',
'open-git-changes-panel'
]);
const props = defineProps<{
@ -425,6 +462,14 @@ const props = defineProps<{
currentContextTokens: number;
versioningEnabled?: boolean;
runtimeQueuedMessages?: Array<{ id: string; text: string }>;
projectGitSummary?: {
has_git?: boolean;
project_name?: string;
project_path?: string;
branch?: string;
additions?: number;
deletions?: number;
} | null;
userQuestionMinimized?: boolean;
pendingUserQuestionCount?: number;
goalModeArmed?: boolean;
@ -495,6 +540,69 @@ const runtimeQueuedMessagesForRender = computed(() => {
}));
});
const projectGitSummaryForRender = computed(() => {
if (personalizationStore?.form?.show_git_status_bar === false) return null;
const summary = props.projectGitSummary;
if (!summary?.has_git) return null;
const projectName = String(summary.project_name || '').trim();
const branch = String(summary.branch || '').trim();
if (!projectName || !branch) return null;
return {
projectName,
projectPath: String(summary.project_path || '').trim(),
branch,
additions: Math.max(0, Number(summary.additions || 0)),
deletions: Math.max(0, Number(summary.deletions || 0))
};
});
const floatingStatusVisible = computed(() => {
if (skillSlashMenuOpen.value || props.quickMenuOpen) return false;
return (
!!projectGitSummaryForRender.value ||
!!props.goalRunning ||
!!props.goalModeArmed ||
(!!props.userQuestionMinimized && Number(props.pendingUserQuestionCount || 0) > 0)
);
});
const projectGitMenuOpen = ref<null | 'project' | 'branch'>(null);
const closeProjectGitMenu = () => {
projectGitMenuOpen.value = null;
};
const toggleProjectGitMenu = (menu: 'project' | 'branch') => {
projectGitMenuOpen.value = projectGitMenuOpen.value === menu ? null : menu;
};
const copyTextToClipboard = async (text: string) => {
const value = String(text || '').trim();
if (!value || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
return;
}
await navigator.clipboard.writeText(value);
closeProjectGitMenu();
};
const copyProjectPath = () => {
copyTextToClipboard(projectGitSummaryForRender.value?.projectPath || '');
};
const copyProjectBranch = () => {
copyTextToClipboard(projectGitSummaryForRender.value?.branch || '');
};
const openProjectInFileManager = async () => {
try {
await fetch('/api/project/open-in-file-manager', { method: 'POST' });
} catch (error) {
console.warn('打开项目文件管理器失败:', error);
} finally {
closeProjectGitMenu();
}
};
const findSlashToken = () => {
const instance = editor.value;
if (!instance) return null;
@ -781,6 +889,7 @@ const refreshSkillSlashState = () => {
closeSlashMenu();
return;
}
closeProjectGitMenu();
skillSlashOpen.value = true;
if (skillSlashQuery.value !== token.query) {
skillSlashActiveIndex.value = 0;
@ -1309,7 +1418,7 @@ const hasRuntimeLayoutExpansion = computed(() => {
const hasQueue = runtimeQueuedMessagesForRender.value.length > 0;
const hasImages = Array.isArray(props.selectedImages) && props.selectedImages.length > 0;
const hasVideos = Array.isArray(props.selectedVideos) && props.selectedVideos.length > 0;
return hasQueue || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
return hasQueue || floatingStatusVisible.value || skillSlashOpen.value || hasImages || hasVideos || !!props.inputIsMultiline || !!props.goalModeArmed || !!props.goalRunning || goalCompleted.value;
});
const goalCompleted = computed(() => String(props.goalProgress?.status || '').toLowerCase() === 'done');
@ -1353,9 +1462,14 @@ const collectComposerVisualHeight = () => {
const shellRect = shell.getBoundingClientRect();
let top = shellRect.top;
let bottom = shellRect.bottom;
const slashMenu = root.querySelector('.skill-slash-menu');
if (slashMenu instanceof HTMLElement) {
const rect = slashMenu.getBoundingClientRect();
return Math.max(0, Math.max(shellRect.bottom, rect.bottom) - Math.min(shellRect.top, rect.top));
}
//
// .goal-mode-banner--collapsed
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .skill-slash-menu, .goal-mode-banner:not(.goal-mode-banner--collapsed)');
const nodes = root.querySelectorAll('.runtime-queue-list:not(.runtime-queue-list--empty), .floating-project-status');
nodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
const rect = node.getBoundingClientRect();
@ -1365,12 +1479,27 @@ const collectComposerVisualHeight = () => {
return Math.max(0, bottom - top);
};
const collectComposerShellHeight = () => {
const shell = compactInputShell.value;
if (!shell) {
return 0;
}
return Math.max(0, shell.getBoundingClientRect().height);
};
const emitComposerHeight = () => {
const visualHeight = collectComposerVisualHeight();
if (!visualHeight) {
return;
}
if (baselineComposerVisualHeight.value <= 0 || !hasRuntimeLayoutExpansion.value) {
const shellHeight = collectComposerShellHeight();
if (!hasRuntimeLayoutExpansion.value) {
baselineComposerVisualHeight.value = visualHeight;
} else if (baselineComposerVisualHeight.value <= 0 && shellHeight > 0) {
baselineComposerVisualHeight.value = shellHeight;
} else if (baselineComposerVisualHeight.value > 0 && shellHeight > 0) {
baselineComposerVisualHeight.value = Math.min(baselineComposerVisualHeight.value, shellHeight);
} else if (baselineComposerVisualHeight.value <= 0) {
baselineComposerVisualHeight.value = visualHeight;
}
const baseline = baselineComposerVisualHeight.value || visualHeight;
@ -1529,6 +1658,7 @@ defineExpose({
compactInputShell,
stadiumInput,
fileUploadInput,
emitComposerHeight,
prepareMessageForSend,
getComposerDraftMeta,
restoreComposerDraftMeta
@ -1586,6 +1716,26 @@ watch(
{ deep: true }
);
watch(
() => props.projectGitSummary,
async () => {
if (!projectGitSummaryForRender.value) {
closeProjectGitMenu();
}
await nextTick();
emitComposerHeight();
},
{ deep: true }
);
watch(floatingStatusVisible, async () => {
await nextTick();
emitComposerHeight();
window.setTimeout(() => {
emitComposerHeight();
}, 280);
});
watch(
() => [props.selectedImages?.length || 0, props.selectedVideos?.length || 0],
async () => {
@ -1602,6 +1752,7 @@ watch(goalBannerCollapsed, async () => {
});
onMounted(() => {
document.addEventListener('click', closeProjectGitMenu);
adjustTextareaSize();
nextTick(() => {
emitComposerHeight();
@ -1623,6 +1774,7 @@ onMounted(() => {
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeProjectGitMenu);
editor.value?.destroy();
runtimeQueueItemRefs.forEach((el) => {
if (el) {
@ -1653,6 +1805,207 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.floating-project-status {
position: absolute;
left: calc(50% - 7px);
bottom: calc(100% + 6px);
z-index: 45;
width: 90%;
max-width: 90%;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 10px 4px 4px;
border: 1px solid var(--claude-border);
border-radius: 14px;
background: var(--theme-surface-muted);
box-shadow: none;
transform: translateX(-50%);
pointer-events: auto;
}
.floating-project-status__left,
.floating-project-status__right {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
.floating-project-status__left {
flex: 1 1 auto;
}
.floating-project-status__right {
flex: 0 0 auto;
margin-left: auto;
}
.floating-project-status__menu-wrap {
position: relative;
display: inline-flex;
align-items: center;
min-width: 0;
height: 26px;
}
.floating-project-status__button,
.floating-project-status__notice {
height: 26px;
display: inline-flex;
align-items: center;
border: none;
border-radius: 10px;
background: transparent;
color: var(--claude-text);
font: inherit;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
}
.floating-project-status__button {
min-width: 0;
padding: 0 9px;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.floating-project-status__button:hover,
.floating-project-status__button--active,
.floating-project-status__notice--button:hover,
.floating-project-status__submenu button:hover {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
}
.floating-project-status__button--project {
max-width: min(260px, 36vw);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
}
.floating-project-status__button--branch {
color: var(--claude-text-secondary);
}
.floating-project-status__notice {
padding: 0 8px;
color: var(--claude-text-secondary);
}
.floating-project-status__notice--button {
cursor: pointer;
}
.floating-project-status__stats {
display: inline-flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 8px;
border: none;
border-radius: 10px;
background: transparent;
font-size: 12px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.floating-project-status__stats:hover {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
}
.floating-project-status__add {
color: var(--git-diff-add-text);
}
.floating-project-status__del {
color: var(--git-diff-del-text);
}
.floating-project-status__submenu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 46;
width: max-content;
min-width: 160px;
max-width: min(260px, 72vw);
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid var(--claude-border);
border-radius: 14px;
background: var(--theme-surface-muted);
box-shadow: none;
overflow: hidden;
}
.floating-project-status__submenu button {
width: 100%;
min-width: 148px;
height: 36px;
padding: 0 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--claude-text);
font-size: 14px;
line-height: 1.2;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
:global(html[data-theme='dark'] .floating-project-status),
:global(body[data-theme='dark'] .floating-project-status),
:global(html[data-theme='dark'] .floating-project-status__submenu),
:global(body[data-theme='dark'] .floating-project-status__submenu) {
background: #2a2a2a !important;
border-color: rgba(255, 255, 255, 0.1);
}
:global(html[data-theme='dark'] .floating-project-status__button:hover),
:global(body[data-theme='dark'] .floating-project-status__button:hover),
:global(html[data-theme='dark'] .floating-project-status__notice--button:hover),
:global(body[data-theme='dark'] .floating-project-status__notice--button:hover),
:global(html[data-theme='dark'] .floating-project-status__stats:hover),
:global(body[data-theme='dark'] .floating-project-status__stats:hover),
:global(html[data-theme='dark'] .floating-project-status__submenu button:hover),
:global(body[data-theme='dark'] .floating-project-status__submenu button:hover) {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
.floating-status-motion-enter-active,
.floating-status-motion-leave-active {
transition:
opacity 180ms ease,
transform 260ms cubic-bezier(0.25, 0.8, 0.25, 1);
transform-origin: bottom center;
}
.floating-status-motion-enter-from,
.floating-status-motion-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(44px);
}
.floating-status-motion-enter-to,
.floating-status-motion-leave-from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* 目标模式横幅:与 + 按钮左对齐,置于输入框上方 */
.goal-mode-banner {
display: inline-flex;

View File

@ -0,0 +1,224 @@
<template>
<aside class="git-changes-panel" :style="{ width: width + 'px' }">
<header class="git-changes-panel__header">
<div class="git-changes-panel__title">
<span class="git-changes-panel__branch">{{ branchLabel }}</span>
<span class="git-changes-panel__arrow"></span>
<span class="git-changes-panel__upstream">{{ upstreamLabel }}</span>
</div>
<div class="git-changes-panel__summary">
<span class="git-changes-panel__add">+{{ additions }}</span>
<span class="git-changes-panel__del">-{{ deletions }}</span>
<button type="button" class="git-changes-panel__close" aria-label="关闭 Git 变更面板" @click="$emit('close')">
×
</button>
</div>
</header>
<div class="git-changes-panel__body">
<div v-if="loading && !files.length" class="git-changes-panel__empty">正在加载 Git 变更</div>
<div v-else-if="error && !files.length" class="git-changes-panel__empty git-changes-panel__empty--error">{{ error }}</div>
<div v-else-if="!files.length" class="git-changes-panel__empty">当前没有未提交变更</div>
<section v-for="file in files" v-else :key="file.path" class="git-change-file">
<div class="git-change-file__header">
<span class="git-change-file__title">
<span class="git-change-file__open-wrap">
<button
type="button"
class="git-change-file__open-btn"
:class="{ open: openMenuPath === file.path || appsLoadingPath === file.path }"
:disabled="!hostMode"
:title="hostMode ? '用应用打开文件' : 'Docker 模式不可用'"
@click.stop.prevent="toggleOpenMenu(file.path)"
>
<img class="git-change-file__open-icon" :src="filePenIcon" alt="" aria-hidden="true" />
<span class="git-change-file__open-caret" aria-hidden="true"></span>
</button>
<div
v-if="openMenuPath === file.path"
class="git-change-file__open-menu"
@click.stop
>
<div v-if="openAppsError" class="git-change-file__open-empty">{{ openAppsError }}</div>
<button
v-for="app in openApps"
v-else
:key="app.id"
type="button"
class="git-change-file__open-option"
@click.stop.prevent="openFileWithApp(file.path, app.id)"
>
<img
class="git-change-file__open-option-icon"
:src="app.icon_url || filePenIcon"
alt=""
aria-hidden="true"
/>
<span>{{ app.label }}</span>
</button>
<div v-if="!appsLoadingPath && !openAppsError && !openApps.length" class="git-change-file__open-empty">
未检测到可用应用
</div>
</div>
</span>
<span class="git-change-file__path">{{ file.path }}</span>
<button
v-if="canResetContext(file.path)"
type="button"
class="git-change-file__reset"
@click.stop.prevent="handleResetContext(file.path)"
>
恢复
</button>
</span>
<span class="git-change-file__stats">
<span class="git-changes-panel__add">+{{ file.additions || 0 }}</span>
<span class="git-changes-panel__del">-{{ file.deletions || 0 }}</span>
</span>
</div>
<div class="git-change-file__diff">
<div class="git-change-file__diff-inner">
<template v-for="(hunk, hunkIndex) in file.hunks" :key="`${file.path}-${hunkIndex}`">
<button
v-if="hunk.before_hidden > 0"
type="button"
class="git-change-file__fold"
@click.stop.prevent="handleExpandContext(file.path, hunk.before_fold_key)"
>
<span class="git-change-file__fold-content">
<span class="git-change-file__fold-icon git-change-file__fold-icon--up" aria-hidden="true"></span>
<span>{{ hunk.before_hidden }} 行未编辑的内容</span>
</span>
</button>
<div
v-for="(line, lineIndex) in hunk.lines"
:key="`${file.path}-${hunkIndex}-${lineIndex}`"
class="git-change-line"
:class="{
'git-change-line--add': line.kind === 'add',
'git-change-line--delete': line.kind === 'delete'
}"
>
<span class="git-change-line__num">{{ formatLineNumber(line) }}</span>
<span class="git-change-line__marker">{{ lineMarker(line.kind) }}</span>
<span class="git-change-line__text">{{ line.text }}</span>
</div>
</template>
<button
v-if="file.after_hidden > 0"
type="button"
class="git-change-file__fold"
@click.stop.prevent="handleExpandContext(file.path, file.after_fold_key || 'after')"
>
<span class="git-change-file__fold-content">
<span class="git-change-file__fold-icon git-change-file__fold-icon--down" aria-hidden="true"></span>
<span>{{ file.after_hidden }} 行未编辑的内容</span>
</span>
</button>
</div>
</div>
</section>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { ref } from 'vue';
defineOptions({ name: 'GitChangesPanel' });
const props = defineProps<{
width: number;
loading?: boolean;
error?: string;
hostMode?: boolean;
foldContexts?: Record<string, Record<string, number>>;
diff: Record<string, any> | null;
}>();
const emit = defineEmits<{
(event: 'close'): void;
(event: 'expand-context', payload: { path: string; foldKey: string }): void;
(event: 'reset-context', path: string): void;
}>();
const branchLabel = computed(() => String(props.diff?.branch || 'HEAD'));
const upstreamLabel = computed(() => String(props.diff?.upstream || '未设置云端分支'));
const additions = computed(() => Math.max(0, Number(props.diff?.additions || 0)));
const deletions = computed(() => Math.max(0, Number(props.diff?.deletions || 0)));
const files = computed(() => (Array.isArray(props.diff?.files) ? props.diff.files : []));
const canResetContext = (path: string) => {
const fileMap = props.foldContexts?.[path];
return !!fileMap && Object.values(fileMap).some((value) => Number(value || 0) > 0);
};
const filePenIcon = new URL('../../../icons/file-pen.svg', import.meta.url).href;
const openMenuPath = ref('');
const openApps = ref<Array<{ id: string; label: string; icon_url?: string }>>([]);
const openAppsError = ref('');
const appsLoadingPath = ref('');
const formatLineNumber = (line: Record<string, any>) => {
const rawValue = line?.kind === 'delete' ? line.old_line : line?.new_line;
const num = Number(rawValue);
return Number.isFinite(num) && num > 0 ? String(num) : '';
};
const lineMarker = (kind: string) => {
if (kind === 'add') return '+';
if (kind === 'delete') return '-';
return ' ';
};
const handleExpandContext = (path: string, foldKey: string) => {
emit('expand-context', { path, foldKey });
};
const handleResetContext = (path: string) => {
emit('reset-context', path);
};
const toggleOpenMenu = async (path: string) => {
if (!props.hostMode) return;
if (openMenuPath.value === path) {
openMenuPath.value = '';
return;
}
openMenuPath.value = '';
openApps.value = [];
openAppsError.value = '';
appsLoadingPath.value = path;
try {
const resp = await fetch(`/api/project/file-open-apps?path=${encodeURIComponent(path)}`);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '检测应用失败');
}
openApps.value = Array.isArray(payload?.data?.apps) ? payload.data.apps : [];
openMenuPath.value = path;
} catch (error: any) {
openAppsError.value = error?.message || '检测应用失败';
openMenuPath.value = path;
} finally {
appsLoadingPath.value = '';
}
};
const openFileWithApp = async (path: string, appId: string) => {
try {
const resp = await fetch('/api/project/open-file-with-app', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, app_id: appId })
});
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '打开文件失败');
}
openMenuPath.value = '';
} catch (error) {
openAppsError.value = error instanceof Error ? error.message : '打开文件失败';
}
};
</script>

View File

@ -534,6 +534,24 @@
</div>
</div>
</div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">默认隐藏工作区</span
><span class="settings-row-desc"
>打开页面时默认收起左侧工作区仍可随时手动展开</span
></span
><input
type="checkbox"
:checked="form.default_hide_workspace"
@change="applyDefaultHideWorkspaceOption($event.target.checked)"
/><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">使用自定义称呼</span
@ -578,6 +596,28 @@
class="fancy-path"
></path></svg></span
></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">显示 Git 状态栏</span
><span class="settings-row-desc"
>工作区存在 Git 仓库时在输入栏上方显示分支和变更统计</span
></span
><input
type="checkbox"
:checked="form.show_git_status_bar"
@change="
personalization.updateField({
key: 'show_git_status_bar',
value: $event.target.checked
})
" /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<div class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">堆叠块显示模式</span
@ -1459,7 +1499,7 @@ import { ref, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia';
import { usePersonalizationStore } from '@/stores/personalization';
import { useResourceStore } from '@/stores/resource';
import { useUiStore } from '@/stores/ui';
import { useUiStore, persistDefaultHideWorkspace } from '@/stores/ui';
import { usePolicyStore } from '@/stores/policy';
import { useTutorialStore } from '@/stores/tutorial';
import { useModelStore } from '@/stores/model';
@ -2284,6 +2324,29 @@ const applyThemeOption = async (theme: ThemeKey) => {
console.warn('保存主题到后端失败:', error);
}
};
const applyDefaultHideWorkspaceOption = async (hidden: boolean) => {
const next = !!hidden;
persistDefaultHideWorkspace(next);
personalization.updateField({ key: 'default_hide_workspace', value: next });
if (next) {
uiStore.setWorkspaceCollapsed(true);
}
try {
const resp = await fetch('/api/personalization', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ default_hide_workspace: next })
});
const result = await resp.json();
if (!resp.ok || !result.success) {
console.warn('保存默认隐藏工作区失败:', result.error);
}
} catch (error) {
console.warn('保存默认隐藏工作区失败:', error);
}
};
</script>
<style scoped>

View File

@ -4,6 +4,7 @@ interface ResizeContext {
isResizing?: boolean;
resizingPanel?: PanelKey | null;
rightCollapsed?: boolean;
gitChangesPanelOpen?: boolean;
rightWidth?: number;
minPanelWidth?: number;
maxPanelWidth?: number;
@ -19,7 +20,7 @@ function clamp(value: number, min: number, max: number) {
export function startResize(ctx: ResizeContext, panel: PanelKey, event: MouseEvent) {
// 如果右侧面板(审批面板)已折叠,禁用拖拽边缘展开
if (panel === 'right' && ctx.rightCollapsed) {
if (panel === 'right' && ctx.rightCollapsed && !ctx.gitChangesPanelOpen) {
return;
}

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia';
import { useModelStore } from './model';
import { persistDefaultHideWorkspace, useUiStore } from './ui';
export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal';
export type CompactMessageDisplay = 'full' | 'brief';
@ -20,6 +21,7 @@ interface PersonalForm {
skill_strict_run_command_background_enabled: boolean;
silent_tool_disable: boolean;
enhanced_tool_display: boolean;
show_git_status_bar: boolean;
enhanced_tool_display_categories: string[];
enabled_skills: string[];
self_identify: string;
@ -47,6 +49,7 @@ interface PersonalForm {
deep_compress_behavior: 'continue' | 'wait';
agents_md_auto_inject: boolean;
allow_root_file_creation: boolean;
default_hide_workspace: boolean;
theme: 'classic' | 'light' | 'dark';
goal_review_mode: 'readonly' | 'active';
goal_end_conditions: string[];
@ -113,6 +116,7 @@ const defaultForm = (): PersonalForm => ({
skill_strict_run_command_background_enabled: false,
silent_tool_disable: false,
enhanced_tool_display: true,
show_git_status_bar: true,
enhanced_tool_display_categories: [],
enabled_skills: [],
self_identify: '',
@ -140,6 +144,7 @@ const defaultForm = (): PersonalForm => ({
deep_compress_behavior: 'continue',
agents_md_auto_inject: false,
allow_root_file_creation: false,
default_hide_workspace: false,
theme: loadCachedTheme(),
goal_review_mode: 'readonly',
goal_end_conditions: ['max_turns'],
@ -290,6 +295,7 @@ export const usePersonalizationStore = defineStore('personalization', {
!!data.skill_strict_run_command_background_enabled,
silent_tool_disable: !!data.silent_tool_disable,
enhanced_tool_display: data.enhanced_tool_display !== false,
show_git_status_bar: data.show_git_status_bar !== false,
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)
? data.enhanced_tool_display_categories.filter((item: unknown) => typeof item === 'string')
: [],
@ -347,6 +353,7 @@ export const usePersonalizationStore = defineStore('personalization', {
deep_compress_behavior: data.deep_compress_behavior === 'wait' ? 'wait' : 'continue',
agents_md_auto_inject: !!data.agents_md_auto_inject,
allow_root_file_creation: !!data.allow_root_file_creation,
default_hide_workspace: !!data.default_hide_workspace,
theme: ['classic', 'light', 'dark'].includes(data.theme) ? data.theme : fallbackTheme,
goal_review_mode: data.goal_review_mode === 'active' ? 'active' : 'readonly',
goal_end_conditions: Array.isArray(data.goal_end_conditions)
@ -370,6 +377,10 @@ export const usePersonalizationStore = defineStore('personalization', {
if (this.form.theme !== currentTheme) {
this.applyTheme(this.form.theme);
}
persistDefaultHideWorkspace(this.form.default_hide_workspace);
if (this.form.default_hide_workspace) {
useUiStore().setWorkspaceCollapsed(true);
}
this.clearFeedback();
},
applyTheme(theme: 'classic' | 'light' | 'dark') {

View File

@ -3,6 +3,21 @@ import { defineStore } from 'pinia';
type PanelMode = 'files' | 'todo' | 'subAgents' | 'backgroundCommands';
type ResizingPanel = 'left' | 'right' | null;
type MobileOverlayTarget = 'conversation' | 'workspace' | 'focus' | 'approval' | null;
const DEFAULT_HIDE_WORKSPACE_STORAGE_KEY = 'agents_default_hide_workspace';
export const loadDefaultHideWorkspace = (): boolean => {
if (typeof window === 'undefined' || !window.localStorage) {
return false;
}
return window.localStorage.getItem(DEFAULT_HIDE_WORKSPACE_STORAGE_KEY) === '1';
};
export const persistDefaultHideWorkspace = (hidden: boolean) => {
if (typeof window === 'undefined' || !window.localStorage) {
return;
}
window.localStorage.setItem(DEFAULT_HIDE_WORKSPACE_STORAGE_KEY, hidden ? '1' : '0');
};
interface QuotaToast {
message: string;
@ -79,7 +94,7 @@ interface UiState {
export const useUiStore = defineStore('ui', {
state: (): UiState => ({
sidebarCollapsed: true,
workspaceCollapsed: false,
workspaceCollapsed: loadDefaultHideWorkspace(),
chatDisplayMode: 'chat',
panelMode: 'files',
panelMenuOpen: false,

View File

@ -22,8 +22,11 @@
--claude-button-hover: #c76541;
--claude-button-active: #a95331;
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
--claude-success: #76b086;
--claude-success: #16a34a;
--claude-warning: #d99845;
--claude-danger: #dc2626;
--git-diff-add-text: #00e000;
--git-diff-del-text: #ff1a1a;
/* Theme-neutral surfaces */
--theme-surface-card: #fffaf4;
--theme-surface-strong: #ffffff;
@ -65,8 +68,11 @@
--claude-button-hover: #c76541;
--claude-button-active: #a95331;
--claude-shadow: 0 14px 36px rgba(61, 57, 41, 0.12);
--claude-success: #76b086;
--claude-success: #16a34a;
--claude-warning: #d99845;
--claude-danger: #dc2626;
--git-diff-add-text: #00e000;
--git-diff-del-text: #ff1a1a;
--theme-surface-card: #fffaf4;
--theme-surface-strong: #ffffff;
--theme-surface-soft: rgba(255, 255, 255, 0.92);
@ -110,6 +116,9 @@
--claude-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
--claude-success: #10b981;
--claude-warning: #f59e0b;
--claude-danger: #dc2626;
--git-diff-add-text: #00e000;
--git-diff-del-text: #ff1a1a;
--theme-surface-card: #ffffff;
--theme-surface-strong: #ffffff;
--theme-surface-soft: #ffffff;
@ -153,6 +162,9 @@
--claude-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
--claude-success: #10b981;
--claude-warning: #f59e0b;
--claude-danger: #dc2626;
--git-diff-add-text: #00e000;
--git-diff-del-text: #ff1a1a;
--theme-surface-card: #0a0a0a;
--theme-surface-strong: #1a1a1a;
--theme-surface-soft: #0f0f0f;

View File

@ -88,7 +88,7 @@
overflow-anchor: none;
padding: 24px;
padding-top: 30px;
padding-bottom: calc(20px + var(--composer-growth-height, 0px));
padding-bottom: 20px;
min-height: 0;
position: relative;
}
@ -104,6 +104,14 @@
flex-direction: column;
}
.messages-bottom-spacer {
width: 1px;
height: var(--composer-growth-height, 0px);
min-height: var(--composer-growth-height, 0px);
pointer-events: none;
user-select: none;
}
.chat-container .input-area {
position: absolute;
left: 0;

View File

@ -27,6 +27,7 @@
.runtime-queue-list {
--runtime-queue-bg: var(--theme-surface-soft);
--runtime-queue-divider: rgba(15, 23, 42, 0.12);
--runtime-queue-border: rgba(15, 23, 42, 0.12);
position: absolute;
left: calc(50% - 7px);
bottom: calc(100% - 2px);
@ -38,7 +39,12 @@
max-width: 90%;
margin: 0;
overflow: visible;
border: none;
border-top: 1px solid var(--runtime-queue-border);
border-right: 1px solid var(--runtime-queue-border);
border-bottom: none;
border-left: 1px solid var(--runtime-queue-border);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
background: transparent;
box-shadow: none;
pointer-events: auto;
@ -260,6 +266,7 @@ body[data-theme='dark'] {
.runtime-queue-list {
--runtime-queue-bg: #2a2a2a;
--runtime-queue-divider: rgba(255, 255, 255, 0.12);
--runtime-queue-border: rgba(255, 255, 255, 0.12);
background: transparent;
}

View File

@ -62,6 +62,441 @@
cursor: pointer;
}
.git-changes-panel {
flex: 0 0 auto;
min-width: 340px;
max-width: min(600px, 52vw);
height: var(--app-viewport, 100vh);
display: flex;
flex-direction: column;
background: var(--claude-left-rail);
border-left: 1px solid var(--claude-border);
color: var(--claude-text);
overflow: hidden;
}
.git-changes-panel__header {
flex: 0 0 auto;
min-height: 54px;
padding: 10px 14px 8px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--claude-border);
}
.git-changes-panel__title {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
}
.git-changes-panel__branch,
.git-changes-panel__upstream {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-changes-panel__upstream,
.git-changes-panel__arrow {
color: var(--claude-text-secondary);
}
.git-changes-panel__summary,
.git-change-file__stats {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.git-changes-panel__add {
color: var(--git-diff-add-text);
}
.git-changes-panel__del {
color: var(--git-diff-del-text);
}
.git-changes-panel__close {
width: 28px;
height: 28px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--claude-text-secondary);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.git-changes-panel__close:hover {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
color: var(--claude-text);
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
}
.git-changes-panel__body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 12px;
scrollbar-color: color-mix(in srgb, var(--claude-text-secondary, #6b7280) 55%, transparent)
color-mix(in srgb, var(--theme-surface-muted, #d1d5db) 70%, transparent);
scrollbar-width: thin;
}
.git-changes-panel * {
scrollbar-color: color-mix(in srgb, var(--claude-text-secondary, #6b7280) 55%, transparent)
color-mix(in srgb, var(--theme-surface-muted, #d1d5db) 70%, transparent);
scrollbar-width: thin;
}
.git-changes-panel::-webkit-scrollbar,
.git-changes-panel ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.git-changes-panel::-webkit-scrollbar-track,
.git-changes-panel ::-webkit-scrollbar-track {
background: color-mix(in srgb, var(--theme-surface-muted, #d1d5db) 70%, transparent);
border-radius: 999px;
margin: 8px;
}
.git-changes-panel::-webkit-scrollbar-thumb,
.git-changes-panel ::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--claude-text-secondary, #6b7280) 55%, transparent);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.git-changes-panel__empty {
padding: 48px 12px;
color: var(--claude-text-secondary);
text-align: center;
font-size: 13px;
}
.git-changes-panel__empty--error {
color: var(--theme-danger, #c00);
}
.git-change-file {
margin-bottom: 14px;
}
.git-change-file__header {
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 8px;
color: var(--claude-text);
font-size: 13px;
font-weight: 600;
}
.git-change-file__title {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
position: relative;
}
.git-change-file__open-wrap {
position: relative;
flex: 0 0 auto;
}
.git-change-file__open-btn {
width: 48px;
height: 26px;
display: inline-grid;
grid-template-columns: 1fr 1fr;
align-items: center;
justify-items: center;
padding: 0;
border: none;
border-radius: 9px;
background: transparent;
color: var(--claude-text-secondary);
cursor: pointer;
}
.git-change-file__open-btn:hover,
.git-change-file__open-btn.open {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
color: var(--claude-text);
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
}
.git-change-file__open-btn:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.git-change-file__open-btn:disabled:hover {
background: transparent;
box-shadow: none;
}
.git-change-file__open-icon,
.git-change-file__open-option-icon {
width: 16px;
height: 16px;
object-fit: contain;
filter: var(--theme-icon-filter, none);
}
html[data-theme='dark'] .git-change-file__open-icon,
body[data-theme='dark'] .git-change-file__open-icon {
filter: invert(1) opacity(0.78);
}
.git-change-file__open-caret {
width: 14px;
height: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.git-change-file__open-caret::before {
content: '';
display: block;
font-size: 16px;
line-height: 14px;
transform: rotate(90deg);
}
.git-change-file__open-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 80;
min-width: 190px;
max-width: min(280px, 70vw);
max-height: 260px;
overflow-y: auto;
padding: 6px;
border: 1px solid var(--claude-border);
border-radius: 12px;
background: var(--theme-surface-muted);
box-shadow: none;
}
.git-change-file__open-option {
width: 100%;
height: 34px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 9px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--claude-text);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.git-change-file__open-option-icon {
width: 20px;
height: 20px;
border-radius: 5px;
filter: none;
}
.git-change-file__open-option:hover {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
}
.git-change-file__open-empty {
padding: 8px 10px;
color: var(--claude-text-secondary);
font-size: 12px;
font-weight: 400;
white-space: nowrap;
}
.git-change-file__path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.git-change-file__reset {
flex: 0 0 auto;
height: 24px;
padding: 0 8px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--claude-text-secondary);
font-size: 12px;
cursor: pointer;
}
.git-change-file__reset:hover {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
color: var(--claude-text);
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
}
.git-change-file__diff {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
}
.git-change-file__diff-inner {
display: inline-grid;
grid-template-columns: minmax(100%, max-content);
width: max-content;
min-width: 100%;
}
.git-change-file__fold {
width: auto;
min-width: 0;
height: 34px;
margin: 4px 0;
padding: 0 10px;
display: block;
border: none;
border-radius: 9px;
background: var(--theme-surface-muted);
color: var(--claude-text-secondary);
font: inherit;
font-family: inherit;
cursor: pointer;
text-align: left;
}
.git-change-file__fold-content {
position: sticky;
left: 10px;
width: max-content;
max-width: calc(100vw - 40px);
height: 100%;
display: inline-flex;
align-items: center;
gap: 8px;
}
.git-change-file__fold:hover {
background: var(--theme-tab-active, rgba(0, 0, 0, 0.055));
color: var(--claude-text);
box-shadow: 0 1px 2px rgba(61, 57, 41, 0.08);
}
.git-change-file__fold-icon {
width: 16px;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 16px;
color: var(--claude-text-tertiary);
text-align: center;
line-height: 1;
}
.git-change-file__fold-icon::before {
content: '';
display: block;
font-size: 18px;
line-height: 16px;
transform-origin: center;
}
.git-change-file__fold-icon--up::before {
transform: rotate(-90deg) translateX(0.5px);
}
.git-change-file__fold-icon--down::before {
transform: rotate(90deg) translateX(0.5px);
}
.git-change-file__hunk-header {
padding: 3px 8px;
color: var(--claude-text-tertiary);
white-space: pre;
}
.git-change-line {
display: grid;
grid-template-columns: 5ch 1ch max-content;
column-gap: 8px;
width: auto;
min-width: 0;
min-height: 22px;
padding: 2px 8px;
align-items: start;
white-space: pre;
}
.git-change-line__num {
color: var(--claude-text-tertiary);
text-align: right;
user-select: none;
}
.git-change-line__marker {
color: var(--claude-text-secondary);
text-align: center;
user-select: none;
}
.git-change-line__text {
min-width: 0;
}
.git-change-line--add {
background: rgba(0, 255, 0, 0.1);
color: #0a0;
}
.git-change-line--delete {
background: rgba(255, 0, 0, 0.1);
color: #c00;
}
.git-changes-panel-slide-enter-active,
.git-changes-panel-slide-leave-active {
transition:
width 0.22s ease,
min-width 0.22s ease,
opacity 0.18s ease,
transform 0.22s ease;
}
.git-changes-panel-slide-enter-from,
.git-changes-panel-slide-leave-to {
width: 0 !important;
min-width: 0;
opacity: 0;
transform: translateX(18px);
}
.no-files {
text-align: center;
color: var(--claude-text-secondary);