Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
GET /api/threads/<id> -> thread detail (posts)
POST /api/threads/<id>/posts -> append post
body: { content, speaker? }
PATCH /api/threads/<id> -> rename title { title }
POST /api/threads/<id>/close -> mark closed
POST /api/threads/<id>/posts/<pid>/reactions -> toggle {emoji, actor?}

Expand Down Expand Up @@ -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/<id> — 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)
Expand Down
80 changes: 80 additions & 0 deletions tests/test_thread_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 22 additions & 0 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 1 addition & 5 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ <h1 id="squad-title">—</h1>
<main id="thread-pane">
<header id="thread-header">
<div class="thread-header-main">
<h2 id="detail-title">选择一个 thread</h2>
<h2 id="detail-title" title="双击重命名">选择一个 thread</h2>
<div id="detail-sub" class="meta"></div>
</div>
<div id="detail-participants" class="avatar-stack"></div>
Expand Down Expand Up @@ -330,10 +330,6 @@ <h2 id="modal-title">New Squad</h2>
这个 squad 的 agent 接到开发任务时会在此目录下创建 worktree。留空 = 纯讨论型 squad。
</small>
</label>
<label>Project directory
<input name="project_dir" id="squad-project-dir" autocomplete="off" spellcheck="false" class="mono" placeholder="/Volumes/DevDisk/symbol/openforge" />
<small class="field-hint" id="squad-project-dir-helper" aria-live="polite">这个 squad 的 agent 接到开发任务时会在此目录下创建 worktree。留空 = 纯讨论型 squad。</small>
</label>
<label>Emoji<input name="emoji" maxlength="8" placeholder="✨" /></label>
<fieldset>
<legend>Members</legend>
Expand Down
Loading