diff --git a/server.py b/server.py index 6b28c0c..7a46a8e 100644 --- a/server.py +++ b/server.py @@ -20,6 +20,7 @@ GET /api/threads/ -> thread detail (posts) POST /api/threads//posts -> append post body: { content, speaker? } + PATCH /api/threads/ -> rename title { title } POST /api/threads//close -> mark closed POST /api/threads//posts//reactions -> toggle {emoji, actor?} @@ -1715,6 +1716,30 @@ def do_PATCH(self): except forge_favorites.FavoriteValidationError as e: self._json({"error": str(e)}, 400) return + # v0.10: PATCH /api/threads/ — rename thread title. + # Body: { title: str }. Empty string allowed (clears explicit title). + m = re.match(rf"^/api/threads/{THREAD_ROUTE_RE}$", url.path) + if m: + tid = m.group(1) + if store.project_thread(tid) is None: + self._json({"error": "unknown thread"}, 404) + return + opts = self._read_json() + if opts is None: + return + if not isinstance(opts, dict): + self._json({"error": "body must be object"}, 400) + return + if "title" not in opts: + self._json({"error": "`title` field required"}, 400) + return + try: + store.set_thread_title(tid, opts["title"]) + except ValueError as e: + self._json({"error": str(e)}, 400) + return + self._json(_serializable_thread(store.project_thread(tid))) + return m = re.match(rf"^/api/threads/{THREAD_ROUTE_RE}/posts/{POST_ID_ROUTE_RE}$", url.path) if m: tid = m.group(1) diff --git a/tests/test_thread_title.py b/tests/test_thread_title.py index 21f7ca8..93cc93d 100644 --- a/tests/test_thread_title.py +++ b/tests/test_thread_title.py @@ -167,3 +167,83 @@ def test_server_create_thread_legacy_content_only(server): # title is auto-derived from first line assert th["title"] assert th["title"] in "legacy-style opening" + + +# ─── rename (PATCH) ──────────────────────────────────────────────── + + +def _patch(url: str, body: dict) -> dict: + req = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json", "X-OpenForge-UI": "1"}, + method="PATCH", + ) + with urllib.request.urlopen(req, timeout=2) as r: + return json.loads(r.read().decode("utf-8")) + + +def test_store_set_thread_title_renames(store): + store.create_squad({ + "id": "rn1", "name": "rn1", "members": ["scott"], "chair": "scott", + }) + t = store.create_thread("rn1", "scott", title="old name") + store.set_thread_title(t["thread_id"], "new name") + proj = store.project_thread(t["thread_id"]) + assert proj["title"] == "new name" + # summarize / list also reflect new title + assert store.summarize_thread(t["thread_id"])["title"] == "new name" + assert store.list_threads_for_squad("rn1")[0]["title"] == "new name" + + +def test_store_set_thread_title_length_validation(store): + store.create_squad({ + "id": "rn2", "name": "rn2", "members": ["scott"], "chair": "scott", + }) + t = store.create_thread("rn2", "scott", title="ok") + with pytest.raises(ValueError): + store.set_thread_title(t["thread_id"], "x" * 81) + + +def test_server_patch_thread_title(server): + _post(f"{server}/api/squads", { + "id": "rnsv", "name": "rnsv", "members": ["scott"], "chair": "scott", + }) + th = _post(f"{server}/api/squads/rnsv/threads", { + "title": "before", "created_by": "scott", + }) + tid = th["thread_id"] + out = _patch(f"{server}/api/threads/{tid}", {"title": "after"}) + assert out["title"] == "after" + # GET returns the new title + assert _get(f"{server}/api/threads/{tid}")["title"] == "after" + + +def test_server_patch_thread_title_unknown_thread(server): + req = urllib.request.Request( + f"{server}/api/threads/th_does_not_exist", + data=json.dumps({"title": "x"}).encode("utf-8"), + headers={"Content-Type": "application/json", "X-OpenForge-UI": "1"}, + method="PATCH", + ) + with pytest.raises(urllib.error.HTTPError) as excinfo: + urllib.request.urlopen(req, timeout=2) + assert excinfo.value.code == 404 + + +def test_server_patch_thread_title_missing_field(server): + _post(f"{server}/api/squads", { + "id": "rnmf", "name": "rnmf", "members": ["scott"], "chair": "scott", + }) + th = _post(f"{server}/api/squads/rnmf/threads", { + "title": "x", "created_by": "scott", + }) + req = urllib.request.Request( + f"{server}/api/threads/{th['thread_id']}", + data=json.dumps({}).encode("utf-8"), + headers={"Content-Type": "application/json", "X-OpenForge-UI": "1"}, + method="PATCH", + ) + with pytest.raises(urllib.error.HTTPError) as excinfo: + urllib.request.urlopen(req, timeout=2) + assert excinfo.value.code == 400 diff --git a/web/app.js b/web/app.js index f91161b..7383c92 100644 --- a/web/app.js +++ b/web/app.js @@ -2707,6 +2707,28 @@ attachComposerPreview(els.postComposerInput); attachComposerPreview(els.threadComposerInput); els.btnSendPost.onclick = submitPost; els.btnCloseThread.onclick = closeCurrentThread; +// v0.10: double-click thread title to rename. +els.detailTitle.addEventListener('dblclick', async () => { + const tid = state.currentThreadId; + if (!tid) return; + const current = state.currentThread?.title || els.detailTitle.textContent || ''; + const next = window.prompt('重命名 thread(最多 80 字):', current); + if (next === null) return; + const title = next.trim(); + if (title === current.trim()) return; + try { + const updated = await apiJson(`/api/threads/${encodeURIComponent(tid)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }); + state.currentThread = updated; + els.detailTitle.textContent = updated.title || updated.preview || '(empty)'; + refreshThreadsForCurrentSquad(); + } catch (e) { + alert('重命名失败:' + (e?.message || e)); + } +}); els.btnRefreshThreads.onclick = refreshThreadsForCurrentSquad; els.btnRefreshDetail.onclick = refreshCurrentThread; if (els.btnPopoutThread) { diff --git a/web/index.html b/web/index.html index af4008e..94de34c 100644 --- a/web/index.html +++ b/web/index.html @@ -134,7 +134,7 @@

-

选择一个 thread

+

选择一个 thread

@@ -330,10 +330,6 @@ 这个 squad 的 agent 接到开发任务时会在此目录下创建 worktree。留空 = 纯讨论型 squad。 -
Members