ログ更新の自動追従を追加 (ON/OFF 切替つき)

ヘッダの「自動追従」で有効/無効を切り替える。状態は localStorage に保持し、
既定は有効。無効時は従来どおり再読込ボタンで更新する。

- Watcher スレッド: --interval 秒ごとに jsonl を stat し、ファイル数・合計
  サイズ・最新 mtime の組が変わったら rev を進めて待機中の SSE を起こす
- /api/watch (SSE) で change を通知、20 秒ごとに ping で接続維持。
  /api/rev で現在の版も取れる
- ブラウザ側は通知を 1.2 秒まとめてから表示中のものだけ黙って作り直す。
  プロジェクト/タスクの選択・スクロール位置・絞り込み・タブは維持し、
  やりとりを末尾まで見ていた場合は追記行に追従する
- 日別集計・ガントの更新は placeholder を出さない silent 経路を追加
- 後から有効にした時に、無効だった間の変更を版の比較で取り込む

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 11:21:05 +09:00
co-authored by Claude Opus 5
parent 0205404797
commit 0d9faa54bb
5 changed files with 273 additions and 19 deletions
+99
View File
@@ -21,6 +21,7 @@ import json
import os
import re
import threading
import time
import webbrowser
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -586,6 +587,72 @@ class Store:
self._cache.clear()
class Watcher(threading.Thread):
"""projects 配下の jsonl を定期的に stat して変更を検出する (自動追従の通知用)。
ファイル数・合計サイズ・最新 mtime の組を「版」として持ち、変わったら rev を進めて
待っているクライアント (SSE) を起こす。解析結果は Store 側が (mtime, size) で
キャッシュしているので、ここでは通知だけを担う。
"""
def __init__(self, root: Path, interval=2.0):
super().__init__(daemon=True)
self.root = root
self.interval = max(0.5, interval)
self.rev = 0
self.newest = None # (project_id, session_id)
self._cv = threading.Condition()
self._token, self.newest = self._scan()
def _scan(self):
files = 0
total = 0
newest_mtime = 0.0
newest = None
if self.root.is_dir():
for entry in self.root.iterdir():
if not entry.is_dir():
continue
for path in entry.glob("*.jsonl"):
try:
stat = path.stat()
except OSError:
continue
files += 1
total += stat.st_size
if stat.st_mtime > newest_mtime:
newest_mtime = stat.st_mtime
newest = (entry.name, path.stem)
return (files, total, round(newest_mtime, 3)), newest
def run(self):
while True:
time.sleep(self.interval)
try:
token, newest = self._scan()
except OSError:
continue
with self._cv:
if token != self._token:
self._token = token
self.newest = newest
self.rev += 1
self._cv.notify_all()
def wait_change(self, rev, timeout):
"""rev より進むまで待って、現在の rev を返す (タイムアウト時は同じ値)。"""
with self._cv:
if self.rev != rev:
return self.rev
self._cv.wait(timeout)
return self.rev
def info(self):
with self._cv:
project, session = self.newest or (None, None)
return {"rev": self.rev, "project": project, "session": session, "interval": self.interval}
CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"')
@@ -750,8 +817,37 @@ class Handler(BaseHTTPRequestHandler):
if route == "/api/reload":
store.clear()
return self.send_json({"ok": True})
if route == "/api/rev":
return self.send_json(self.server.watcher.info())
if route == "/api/watch":
return self.watch()
raise FileNotFoundError(route)
def watch(self):
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。"""
watcher = self.server.watcher
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Accel-Buffering", "no")
self.end_headers()
rev = watcher.rev
try:
self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n")
while True:
current = watcher.wait_change(rev, 20)
if current != rev:
rev = current
self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n")
else:
self.stream(": ping\n\n") # 接続維持
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError):
return # タブを閉じた等
def stream(self, text):
self.wfile.write(text.encode("utf-8"))
self.wfile.flush()
def static(self, route):
name = "index.html" if route in ("/", "") else route.lstrip("/")
target = (WEB_DIR / name).resolve()
@@ -793,6 +889,7 @@ def main():
parser.add_argument("--port", type=int, default=8787)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
parser.add_argument("--no-browser", action="store_true")
args = parser.parse_args()
@@ -812,6 +909,8 @@ def main():
webbrowser.open(url)
return
server.store = Store(root)
server.watcher = Watcher(root, args.interval)
server.watcher.start()
print(f"chktime: {root}")
print(f" -> {url} (Ctrl+C で終了)")
if not args.no_browser: