Skip to content

Commit cea6324

Browse files
committed
release: finalize 0.4.7
1 parent 943ecff commit cea6324

File tree

12 files changed

+285
-12
lines changed

12 files changed

+285
-12
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# CCCC v0.4.7 Release Notes
2+
3+
`v0.4.7` builds on `v0.4.6` with a focused set of workflow, presentation, and Web UX improvements.
4+
5+
Compared with `v0.4.6`, this release makes Presentation a first-class shared workspace, tightens task workflow handling across Web and MCP, adds Web branding controls, and cleans up several runtime and operator-facing rough edges.
6+
7+
## Highlights
8+
9+
## 1) Presentation became a real shared workspace
10+
11+
The main change in `v0.4.7` is the new Presentation surface.
12+
13+
Groups can now publish and manage slot-based presentation content through the daemon, Web UI, and MCP layer. The Chat surface also gained a dedicated Presentation rail and viewer flow, so shared artifacts no longer have to live only as chat text.
14+
15+
## 2) Interactive browser-backed views landed
16+
17+
`v0.4.7` also adds the browser-surface path for Presentation.
18+
19+
This makes it possible to handle harder Web content in a more useful way than a simple static preview. The viewer lifecycle and controls were refined as part of the same work, including better handling for refresh, fullscreen, replacement, and URL entry.
20+
21+
## 3) Chat and Presentation are now connected by references
22+
23+
Presentation references were added to the message flow, which means chat can point back to a specific Presentation view instead of relying on vague text-only handoffs.
24+
25+
Snapshot and compare behavior were also added so the quoted view can be preserved more clearly when users and agents are discussing live content.
26+
27+
## 4) Task workflow handling is stricter
28+
29+
This release also improves the task workflow layer.
30+
31+
Task state handling in the Web UI is more structured, context/task workflow logic is tighter, and MCP-side task update compatibility was improved so task status changes are less fragile in practice.
32+
33+
## 5) Web polish and branding moved forward
34+
35+
`v0.4.7` includes a broad Web polish pass as well.
36+
37+
Highlights include:
38+
39+
- Web branding controls for product name and logo assets
40+
- stronger group/context/unread refresh behavior
41+
- general message, panel, and console usability cleanup
42+
43+
## 6) Runtime and startup behavior were cleaned up further
44+
45+
This release also includes smaller but important runtime fixes.
46+
47+
Notably, the default `cccc` entry path now respects top-level `--host` / `--port` overrides throughout supervised Web startup and restart, and Kimi runtime defaults were updated to match the current preferred path.
48+
49+
## Summary
50+
51+
In short, `v0.4.7` is the release that brings together:
52+
53+
1. the first real Presentation workspace
54+
2. browser-backed interactive presentation views
55+
3. chat-to-presentation references
56+
4. tighter task workflow handling
57+
5. Web branding and usability polish
58+
6. another round of runtime/startup cleanup

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "cccc-pair"
7-
version = "0.4.7rc1"
7+
version = "0.4.7"
88
description = "Global multi-agent delivery kernel with working groups, scopes, and an append-only collaboration ledger"
99
readme = { file = "README.md", content-type = "text/markdown" }
1010
requires-python = ">=3.9"

src/cccc/cli/common.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,20 @@ def _resolve_web_server_binding() -> tuple[str, int]:
8686
return host, port
8787

8888

89+
def _resolve_invocation_web_server_binding(
90+
*,
91+
web_host_override: str = "",
92+
web_port_override: Optional[int] = None,
93+
) -> tuple[str, int]:
94+
host, port = _resolve_web_server_binding()
95+
override_host = str(web_host_override or "").strip()
96+
if override_host:
97+
host = override_host
98+
if web_port_override is not None:
99+
port = int(web_port_override)
100+
return host, int(port)
101+
102+
89103
def _default_entry_lock_path(home: Path) -> Path:
90104
return home / "daemon" / "cccc-app.lock"
91105

@@ -432,7 +446,7 @@ def _show_welcome() -> None:
432446
print("=" * 60)
433447
print()
434448

435-
def _default_entry() -> int:
449+
def _default_entry(*, web_host_override: str = "", web_port_override: Optional[int] = None) -> int:
436450
"""Default entry: start daemon + web together, stop both on Ctrl+C."""
437451
import threading
438452

@@ -571,8 +585,15 @@ def _stop_daemon() -> None:
571585
_lifecycle.stop_daemon()
572586
daemon_process = _lifecycle.process
573587

574-
# Keep runtime binding aligned with remote_access settings/UI.
575-
host, port = _resolve_web_server_binding()
588+
# Saved binding is the baseline, but top-level `cccc --host/--port`
589+
# must win for this invocation, including supervised child restarts.
590+
def _resolve_invocation_binding() -> tuple[str, int]:
591+
return _resolve_invocation_web_server_binding(
592+
web_host_override=web_host_override,
593+
web_port_override=web_port_override,
594+
)
595+
596+
host, port = _resolve_invocation_binding()
576597
log_level = str(os.environ.get("CCCC_WEB_LOG_LEVEL") or "").strip() or "info"
577598
reload_mode = _env_flag("CCCC_WEB_RELOAD", default=False)
578599
web_process = None
@@ -640,7 +661,7 @@ def _print_web_banner(cur_host: str, cur_port: int) -> None:
640661
reload=reload_mode,
641662
log_level=log_level,
642663
launch_source="default_entry",
643-
resolve_binding=_resolve_web_server_binding,
664+
resolve_binding=_resolve_invocation_binding,
644665
log=lambda msg: print(f"[cccc] {msg}", file=sys.stderr),
645666
)
646667
if restarted is None:

src/cccc/cli/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,12 @@ def main(argv: Optional[list[str]] = None) -> int:
523523
previous_env, applied_env = _apply_invocation_web_overrides(args)
524524
try:
525525
if not getattr(args, "cmd", None):
526-
return int(_default_entry())
526+
return int(
527+
_default_entry(
528+
web_host_override=str(getattr(args, "web_host", "") or ""),
529+
web_port_override=getattr(args, "web_port", None),
530+
)
531+
)
527532
return int(args.func(args))
528533
finally:
529534
_restore_invocation_web_overrides(previous_env, applied_env)

tests/test_cli_default_entry_ownership.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,84 @@ def start(self) -> None:
257257
finally:
258258
cleanup()
259259

260+
def test_default_entry_applies_invocation_port_override_to_initial_web_start(self) -> None:
261+
from cccc.cli import common
262+
263+
home, cleanup = self._with_home()
264+
try:
265+
daemon_proc = _DaemonProc()
266+
web_proc = object()
267+
268+
class _DummyThread:
269+
def __init__(self, *args, **kwargs) -> None:
270+
_ = args, kwargs
271+
272+
def start(self) -> None:
273+
return None
274+
275+
with patch.object(common, "_is_first_run", return_value=False), patch(
276+
"cccc.paths.ensure_home", return_value=home
277+
), patch.object(common, "_acquire_default_entry_lock", return_value=("lock", None)), patch.object(
278+
common, "_stop_existing_web_runtime", return_value=True
279+
), patch.object(common, "_stop_existing_daemon", return_value=True), patch.object(
280+
common, "_resolve_web_server_binding", return_value=("0.0.0.0", 8848)
281+
), patch.object(common, "call_daemon", return_value={"ok": True}), patch.object(
282+
common.subprocess, "Popen", return_value=daemon_proc
283+
), patch.object(common, "start_supervised_web_child", return_value=(web_proc, None)) as mock_start_web, patch.object(
284+
common, "wait_for_child_exit_interruptibly", side_effect=KeyboardInterrupt()
285+
), patch.object(common, "stop_web_child", return_value=True), patch.object(
286+
common, "release_lockfile"
287+
), patch("threading.Thread", _DummyThread):
288+
ret = common._default_entry(web_port_override=9000)
289+
290+
self.assertEqual(ret, 0)
291+
self.assertEqual(mock_start_web.call_args.kwargs["host"], "0.0.0.0")
292+
self.assertEqual(mock_start_web.call_args.kwargs["port"], 9000)
293+
finally:
294+
cleanup()
295+
296+
def test_default_entry_keeps_invocation_port_override_on_web_restart(self) -> None:
297+
from cccc.cli import common
298+
299+
home, cleanup = self._with_home()
300+
try:
301+
daemon_proc = _DaemonProc()
302+
first_web_proc = unittest.mock.Mock(pid=1111)
303+
restarted_web_proc = unittest.mock.Mock(pid=2222)
304+
305+
class _DummyThread:
306+
def __init__(self, *args, **kwargs) -> None:
307+
_ = args, kwargs
308+
309+
def start(self) -> None:
310+
return None
311+
312+
def _restart_with_assertion(**kwargs):
313+
self.assertEqual(kwargs["resolve_binding"](), ("0.0.0.0", 9000))
314+
return restarted_web_proc, "0.0.0.0", 9000
315+
316+
with patch.object(common, "_is_first_run", return_value=False), patch(
317+
"cccc.paths.ensure_home", return_value=home
318+
), patch.object(common, "_acquire_default_entry_lock", return_value=("lock", None)), patch.object(
319+
common, "_stop_existing_web_runtime", return_value=True
320+
), patch.object(common, "_stop_existing_daemon", return_value=True), patch.object(
321+
common, "_resolve_web_server_binding", return_value=("0.0.0.0", 8848)
322+
), patch.object(common, "call_daemon", return_value={"ok": True}), patch.object(
323+
common.subprocess, "Popen", return_value=daemon_proc
324+
), patch.object(common, "start_supervised_web_child", return_value=(first_web_proc, None)), patch.object(
325+
common, "wait_for_child_exit_interruptibly", side_effect=[common.WEB_RUNTIME_RESTART_EXIT_CODE, KeyboardInterrupt()]
326+
), patch.object(
327+
common, "restart_supervised_web_child_with_fallback", side_effect=_restart_with_assertion
328+
) as mock_restart, patch.object(common, "stop_web_child", return_value=True), patch.object(
329+
common, "release_lockfile"
330+
), patch("threading.Thread", _DummyThread):
331+
ret = common._default_entry(web_port_override=9000)
332+
333+
self.assertEqual(ret, 0)
334+
self.assertEqual(mock_restart.call_count, 1)
335+
finally:
336+
cleanup()
337+
260338

261339
if __name__ == "__main__":
262340
unittest.main()

tests/test_cli_main.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,17 @@ def test_main_uses_default_entry_when_no_subcommand(self) -> None:
1414
rc = cli_main.main([])
1515

1616
self.assertEqual(rc, 7)
17-
mock_default.assert_called_once_with()
17+
mock_default.assert_called_once_with(web_host_override="", web_port_override=None)
1818

1919
def test_main_applies_top_level_port_override_to_default_entry_only_for_invocation(self) -> None:
2020
cli_main = importlib.import_module("cccc.cli.main")
2121

2222
old_port = os.environ.get("CCCC_WEB_PORT")
2323

24-
def _assert_port_and_return() -> int:
24+
def _assert_port_and_return(*, web_host_override: str, web_port_override: int | None) -> int:
2525
self.assertEqual(os.environ.get("CCCC_WEB_PORT"), "9000")
26+
self.assertEqual(web_host_override, "")
27+
self.assertEqual(web_port_override, 9000)
2628
return 0
2729

2830
try:
@@ -35,7 +37,7 @@ def _assert_port_and_return() -> int:
3537
os.environ["CCCC_WEB_PORT"] = old_port
3638

3739
self.assertEqual(rc, 0)
38-
mock_default.assert_called_once_with()
40+
mock_default.assert_called_once_with(web_host_override="", web_port_override=9000)
3941
self.assertEqual(os.environ.get("CCCC_WEB_PORT"), old_port)
4042

4143
def test_main_accepts_top_level_port_override_before_subcommand(self) -> None:

web/src/components/presentation/PresentationPinModal.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PresentationSlot, PresentationWorkspaceItem } from "../../types";
44
import { fetchPresentationWorkspaceListing } from "../../services/api";
55
import { useModalA11y } from "../../hooks/useModalA11y";
66
import { classNames } from "../../utils/classNames";
7+
import { isValidPresentationWebUrl, normalizePresentationUrlInput } from "../../utils/presentation";
78
import { ModalFrame } from "../modals/ModalFrame";
89

910
type PresentationPinModalProps = {
@@ -231,9 +232,18 @@ export function PresentationPinModal({
231232
setError(t("presentationUrlRequired", { defaultValue: "Enter a URL first." }));
232233
return;
233234
}
235+
const normalizedUrl = normalizePresentationUrlInput(trimmedUrl);
236+
if (!isValidPresentationWebUrl(normalizedUrl)) {
237+
setError(
238+
t("presentationUrlInvalid", {
239+
defaultValue: "This does not look like a valid URL. Try example.com or localhost:3000.",
240+
}),
241+
);
242+
return;
243+
}
234244
await onSubmitUrl({
235245
slotId,
236-
url: trimmedUrl,
246+
url: normalizedUrl,
237247
title: String(title || "").trim(),
238248
summary: String(summary || "").trim(),
239249
});

web/src/i18n/locales/en/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
"presentationSummaryLabel": "Summary",
154154
"presentationSummaryPlaceholder": "Optional summary shown in the slot preview",
155155
"presentationUrlRequired": "Enter a URL first.",
156+
"presentationUrlInvalid": "This does not look like a valid URL. Try example.com or localhost:3000.",
156157
"presentationFileRequired": "Choose a file first.",
157158
"presentationPinSubmit": "Pin to slot",
158159
"presentationReplaceSubmit": "Save changes",

web/src/i18n/locales/ja/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
"presentationSummaryLabel": "要約",
154154
"presentationSummaryPlaceholder": "任意。スロットのプレビューに表示されます",
155155
"presentationUrlRequired": "先に URL を入力してください。",
156+
"presentationUrlInvalid": "有効な URL に見えません。example.com または localhost:3000 を試してください。",
156157
"presentationFileRequired": "先にファイルを選択してください。",
157158
"presentationPinSubmit": "このスロットに固定",
158159
"presentationReplaceSubmit": "変更を保存",

web/src/i18n/locales/zh/chat.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@
153153
"presentationSummaryLabel": "摘要",
154154
"presentationSummaryPlaceholder": "可选,会显示在槽位预览里",
155155
"presentationUrlRequired": "请先输入 URL。",
156+
"presentationUrlInvalid": "这看起来不像有效的 URL。可以试试 example.com 或 localhost:3000。",
156157
"presentationFileRequired": "请先选择文件。",
157158
"presentationPinSubmit": "固定到槽位",
158159
"presentationReplaceSubmit": "保存修改",

0 commit comments

Comments
 (0)