Close the remaining gaps on Project and Cowork against the wireframes
Re-read both drawings element by element rather than by eye. Five things were
still wrong.
Cowork headed itself "Cowork" — the screen's own name, which the rail already
shows — where the drawing puts the THREAD's title. The title now follows the
conversation, falling back to the screen name for a chat that has none yet;
ChatPanel notifies on load, on reset and when a title is first derived from the
opening turn.
Its files panel read "Tệp đầu ra"; every section heading in the drawings is
caps, so it matches the rail and Monitoring now.
Project kept its three-line explanation above the panes on every visit. The
drawing heads a populated screen with the title alone and gives the text to the
EMPTY state instead, which is where it is actually needed — so it shows only
when there is no project yet.
The workspace path sat as grey caption text where the drawing shows a field. A
read-only line edit looks like one and lets the path be selected and copied.
And Lưu project sat directly under the folder row; the drawing floats it at the
foot of the panel, so a stretch went in above it.
Checked against the control inventory, not just the picture: "Nén" and "Thư mục
Local…" are marked giữ nguyên tại chỗ, so they stay in the status strip even
though the drawing's status line is text only.
New check_cowork_screen; check_project_screen gains the three points. Both
mutation-tested — pinning the title back to tr("cowork.title") and making the
hint unconditional each fail.
23/23 checkers pass. The Qt teardown segfault is still around: check_no_hscroll
took it 1 run in 3, after printing its verdict.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
300c10711e
commit
7d9a4e2378
@@ -0,0 +1,99 @@
|
|||||||
|
"""Workspace ▸ Cowork against its wireframe (section 5).
|
||||||
|
|
||||||
|
The drawing heads the screen with the THREAD's title — "Gom số liệu doanh thu",
|
||||||
|
not the word "Cowork" — with the model beside it, Skills and Cuộc trò chuyện mới
|
||||||
|
on the right, and a caps TỆP ĐẦU RA (n) panel down the side.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(REPO.parent))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from capture_screens import ( # noqa: E402
|
||||||
|
_apply_theme, _freeze_schedulers, _isolate_home, _load_fonts)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
sandbox = _isolate_home()
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
_load_fonts()
|
||||||
|
_freeze_schedulers()
|
||||||
|
_apply_theme(app)
|
||||||
|
|
||||||
|
from cowork_local.config import CONFIG_DIR
|
||||||
|
assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}"
|
||||||
|
|
||||||
|
from seed_demo_data import seed
|
||||||
|
seed()
|
||||||
|
|
||||||
|
from cowork_local.app import MainWindow
|
||||||
|
from cowork_local.config import AppConfig
|
||||||
|
from cowork_local.core.history import list_conversations, load_conversation
|
||||||
|
from cowork_local.i18n import set_language, tr
|
||||||
|
from cowork_local.state import AppContext
|
||||||
|
|
||||||
|
set_language("vi")
|
||||||
|
win = MainWindow(AppContext(AppConfig.load()), user_name="local")
|
||||||
|
win.resize(1920, 1000)
|
||||||
|
win.show()
|
||||||
|
app.processEvents()
|
||||||
|
win._goto(win._ROW_WORKSPACE, win.workspace._cowork_tab_idx)
|
||||||
|
app.processEvents()
|
||||||
|
c = win.cowork
|
||||||
|
fails = []
|
||||||
|
|
||||||
|
# a new thread has no title yet, so the screen name stands in
|
||||||
|
print(f"chua mo thread: tieu de={c._title_lbl.text()!r}")
|
||||||
|
if not c._title_lbl.text().strip():
|
||||||
|
fails.append("tieu de trong khi chua mo thread")
|
||||||
|
|
||||||
|
convs = list_conversations()
|
||||||
|
if not convs:
|
||||||
|
fails.append("khong co hoi thoai de kiem")
|
||||||
|
else:
|
||||||
|
conv = load_conversation(Path(convs[0]["path"]))
|
||||||
|
c.load_conversation(conv)
|
||||||
|
app.processEvents()
|
||||||
|
want = conv.get("title", "")
|
||||||
|
print(f"sau khi mo thread: tieu de={c._title_lbl.text()!r} (thread={want!r})")
|
||||||
|
if want and c._title_lbl.text() != want:
|
||||||
|
fails.append(f"tieu de khong theo thread: {c._title_lbl.text()!r} != {want!r}")
|
||||||
|
if c._title_lbl.text() == tr("cowork.title") and want:
|
||||||
|
fails.append("tieu de van la ten man hinh")
|
||||||
|
|
||||||
|
# the files panel is a caps section carrying its own count
|
||||||
|
head = c.output_section.header.text()
|
||||||
|
print(f"pane tep dau ra: {head!r}")
|
||||||
|
body = head.lstrip("▾▸ ").split(" (")[0]
|
||||||
|
if body != body.upper():
|
||||||
|
fails.append(f"tieu de pane chua viet hoa: {head!r}")
|
||||||
|
if "(" not in head:
|
||||||
|
fails.append("tieu de pane khong kem so luong")
|
||||||
|
|
||||||
|
# toolbar keeps both actions the drawing shows
|
||||||
|
for name, btn in (("Skills", c.skills_btn), ("chat moi", c._new_btn)):
|
||||||
|
if not btn.isVisible():
|
||||||
|
fails.append(f"thieu nut {name} tren thanh cong cu")
|
||||||
|
print(f"nut tren thanh cong cu: {c.skills_btn.text()!r}, {c._new_btn.text()!r}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
for f in fails:
|
||||||
|
print("FAIL " + f)
|
||||||
|
print("PASS man Cowork khop ban ve" if not fails else f"{len(fails)} problem(s)")
|
||||||
|
sys.stdout.flush()
|
||||||
|
os._exit(1 if fails else 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -103,7 +103,27 @@ def main() -> int:
|
|||||||
fails.append(f"thieu nhan {label!r}")
|
fails.append(f"thieu nhan {label!r}")
|
||||||
print(f"nhan form: {want}")
|
print(f"nhan form: {want}")
|
||||||
|
|
||||||
# 5. the rail's picker must name the projects. It reads project_choices(),
|
# 5. the drawing heads a populated screen with the title alone; the
|
||||||
|
# explanation belongs to an empty one.
|
||||||
|
print(f"hint hien voi {lst.count()} project: {w._hint.isVisible()}")
|
||||||
|
if lst.count() and w._hint.isVisible():
|
||||||
|
fails.append("doan giai thich van hien du da co project")
|
||||||
|
|
||||||
|
# 6. the path is a field in the drawing, not caption text
|
||||||
|
from PySide6.QtWidgets import QLineEdit
|
||||||
|
is_field = isinstance(w.folder_lbl, QLineEdit) and w.folder_lbl.isReadOnly()
|
||||||
|
print(f"o thu muc: {type(w.folder_lbl).__name__} (o nhap chi doc={is_field})")
|
||||||
|
if not is_field:
|
||||||
|
fails.append("duong dan thu muc khong phai o nhap chi doc")
|
||||||
|
|
||||||
|
# 7. Lưu project floats at the foot of the panel, not right under the form
|
||||||
|
save_y = w._save_btn.mapTo(w, QPoint(0, 0)).y()
|
||||||
|
folder_y = w.folder_lbl.mapTo(w, QPoint(0, 0)).y()
|
||||||
|
print(f"nut Luu y={save_y}, o thu muc y={folder_y}, cach {save_y - folder_y}px")
|
||||||
|
if save_y - folder_y < 80:
|
||||||
|
fails.append("nut Luu khong bi day xuong day panel")
|
||||||
|
|
||||||
|
# 8. the rail's picker must name the projects. It reads project_choices(),
|
||||||
# which used to read item.text() — and when rows became widgets the item
|
# which used to read item.text() — and when rows became widgets the item
|
||||||
# text went empty, so every entry showed as a bare folder glyph. Creating
|
# text went empty, so every entry showed as a bare folder glyph. Creating
|
||||||
# a project is when a user notices, so create one here.
|
# a project is when a user notices, so create one here.
|
||||||
|
|||||||
+11
-2
@@ -84,6 +84,7 @@ class ChatPanel(QWidget):
|
|||||||
self.session_name = session_name
|
self.session_name = session_name
|
||||||
self.session_id = new_session_id()
|
self.session_id = new_session_id()
|
||||||
self.title = ""
|
self.title = ""
|
||||||
|
self._notify_title()
|
||||||
# Which project (workspace) this conversation belongs to — every new
|
# Which project (workspace) this conversation belongs to — every new
|
||||||
# thread inherits the currently selected project (Claude-Projects style).
|
# thread inherits the currently selected project (Claude-Projects style).
|
||||||
self.project_id = "default"
|
self.project_id = "default"
|
||||||
@@ -202,7 +203,7 @@ class ChatPanel(QWidget):
|
|||||||
# Right sidebar: Output files only (see below — Input is tracked but
|
# Right sidebar: Output files only (see below — Input is tracked but
|
||||||
# not shown).
|
# not shown).
|
||||||
self.input_section = CollapsibleSection(tr("widgets.input_files"))
|
self.input_section = CollapsibleSection(tr("widgets.input_files"))
|
||||||
self.output_section = CollapsibleSection(tr("widgets.output_files"))
|
self.output_section = CollapsibleSection(tr("widgets.output_files").upper())
|
||||||
# Input files are NOT shown in Cowork's UI anymore — but they're still
|
# Input files are NOT shown in Cowork's UI anymore — but they're still
|
||||||
# fully tracked (add/remove/paths()) exactly as before, since that list
|
# fully tracked (add/remove/paths()) exactly as before, since that list
|
||||||
# is what gets written into the conversation's own "inputs" field on
|
# is what gets written into the conversation's own "inputs" field on
|
||||||
@@ -285,7 +286,7 @@ class ChatPanel(QWidget):
|
|||||||
self.compress_btn.setText(tr("chatpanel.compress_btn"))
|
self.compress_btn.setText(tr("chatpanel.compress_btn"))
|
||||||
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
|
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
|
||||||
self.input_section.set_title(tr("widgets.input_files"))
|
self.input_section.set_title(tr("widgets.input_files"))
|
||||||
self.output_section.set_title(tr("widgets.output_files"))
|
self.output_section.set_title(tr("widgets.output_files").upper())
|
||||||
self.plan_section.set_title(tr("widgets.plan_title"))
|
self.plan_section.set_title(tr("widgets.plan_title"))
|
||||||
self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip"))
|
self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip"))
|
||||||
self._files_header.setText(tr("chatpanel.files_header"))
|
self._files_header.setText(tr("chatpanel.files_header"))
|
||||||
@@ -1024,6 +1025,7 @@ class ChatPanel(QWidget):
|
|||||||
if not self.title:
|
if not self.title:
|
||||||
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
||||||
self.title = (base[:60] + "…") if len(base) > 60 else base
|
self.title = (base[:60] + "…") if len(base) > 60 else base
|
||||||
|
self._notify_title()
|
||||||
|
|
||||||
# Reset the Plan panel so each message starts from a clean checklist (the
|
# Reset the Plan panel so each message starts from a clean checklist (the
|
||||||
# previous message's plan never lingers/flickers into this one).
|
# previous message's plan never lingers/flickers into this one).
|
||||||
@@ -1669,6 +1671,12 @@ class ChatPanel(QWidget):
|
|||||||
self._sync_indicators()
|
self._sync_indicators()
|
||||||
self.history_changed.emit() # current view changed → refresh History highlight
|
self.history_changed.emit() # current view changed → refresh History highlight
|
||||||
|
|
||||||
|
def _notify_title(self) -> None:
|
||||||
|
"""Let a screen that heads itself with the thread title follow along."""
|
||||||
|
hook = getattr(self, "refresh_title", None)
|
||||||
|
if callable(hook):
|
||||||
|
hook()
|
||||||
|
|
||||||
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
||||||
"""Switch the view to a stored conversation. Allowed while work is running —
|
"""Switch the view to a stored conversation. Allowed while work is running —
|
||||||
the current turns keep going in the background."""
|
the current turns keep going in the background."""
|
||||||
@@ -1680,6 +1688,7 @@ class ChatPanel(QWidget):
|
|||||||
self._detach_live_turns()
|
self._detach_live_turns()
|
||||||
self.session_id = sid
|
self.session_id = sid
|
||||||
self.title = conv.get("title", "")
|
self.title = conv.get("title", "")
|
||||||
|
self._notify_title()
|
||||||
self.project_id = conv.get("project_id", "") or "default"
|
self.project_id = conv.get("project_id", "") or "default"
|
||||||
# If this conversation still has a turn running in the background, attach to
|
# If this conversation still has a turn running in the background, attach to
|
||||||
# its LIVE message list (not a stale disk copy) so the two never race on save.
|
# its LIVE message list (not a stale disk copy) so the two never race on save.
|
||||||
|
|||||||
+14
-1
@@ -81,8 +81,21 @@ class CoworkTab(ChatPanel):
|
|||||||
# succeeds (see _cleanup_turn) — never intermediate files or a folder name.
|
# succeeds (see _cleanup_turn) — never intermediate files or a folder name.
|
||||||
on_language_changed(self._retranslate)
|
on_language_changed(self._retranslate)
|
||||||
|
|
||||||
|
def refresh_title(self) -> None:
|
||||||
|
"""Head the screen with the thread you are in, as the drawing does.
|
||||||
|
|
||||||
|
It said "Cowork" on every conversation — the screen's own name, which
|
||||||
|
the rail already shows. The thread's title is the thing that changes and
|
||||||
|
the thing that tells you where you are; a thread with no title yet (a
|
||||||
|
new chat, before its first turn) falls back to the screen name.
|
||||||
|
"""
|
||||||
|
lbl = getattr(self, "_title_lbl", None)
|
||||||
|
if lbl is None:
|
||||||
|
return # ChatPanel.__init__ sets self.title before we exist
|
||||||
|
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
|
||||||
|
|
||||||
def _retranslate(self) -> None:
|
def _retranslate(self) -> None:
|
||||||
self._title_lbl.setText(tr("cowork.title"))
|
self.refresh_title()
|
||||||
self.skills_btn.setText(tr("cowork.skills_btn"))
|
self.skills_btn.setText(tr("cowork.skills_btn"))
|
||||||
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
|
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
|
||||||
self._new_btn.setText(tr("cowork.new_chat"))
|
self._new_btn.setText(tr("cowork.new_chat"))
|
||||||
|
|||||||
+12
-3
@@ -274,8 +274,11 @@ class WorkspaceTab(QWidget):
|
|||||||
self._folder_hdr = QLabel()
|
self._folder_hdr = QLabel()
|
||||||
rl.addWidget(self._folder_hdr)
|
rl.addWidget(self._folder_hdr)
|
||||||
folder_row = QHBoxLayout()
|
folder_row = QHBoxLayout()
|
||||||
self.folder_lbl = QLabel()
|
# The drawing shows the path in a field, not as grey caption text. A
|
||||||
self.folder_lbl.setObjectName("hint")
|
# read-only line edit looks like one and, unlike a label, lets the path
|
||||||
|
# be selected and copied.
|
||||||
|
self.folder_lbl = QLineEdit()
|
||||||
|
self.folder_lbl.setReadOnly(True)
|
||||||
self._browse_btn = QPushButton()
|
self._browse_btn = QPushButton()
|
||||||
self._browse_btn.setIcon(icon("folder"))
|
self._browse_btn.setIcon(icon("folder"))
|
||||||
self._browse_btn.clicked.connect(self._pick_folder)
|
self._browse_btn.clicked.connect(self._pick_folder)
|
||||||
@@ -287,6 +290,7 @@ class WorkspaceTab(QWidget):
|
|||||||
folder_row.addWidget(self._open_btn)
|
folder_row.addWidget(self._open_btn)
|
||||||
rl.addLayout(folder_row)
|
rl.addLayout(folder_row)
|
||||||
|
|
||||||
|
rl.addStretch(1) # the drawing floats Lưu project at the bottom
|
||||||
save_row = QHBoxLayout()
|
save_row = QHBoxLayout()
|
||||||
self._save_btn = QPushButton()
|
self._save_btn = QPushButton()
|
||||||
self._save_btn.setIcon(icon("save"))
|
self._save_btn.setIcon(icon("save"))
|
||||||
@@ -394,7 +398,9 @@ class WorkspaceTab(QWidget):
|
|||||||
# space taken from Co4E's canvas and Folder's tree on every laptop
|
# space taken from Co4E's canvas and Folder's tree on every laptop
|
||||||
# screen. Shown where they apply; the text itself is unchanged.
|
# screen. Shown where they apply; the text itself is unchanged.
|
||||||
self._header.setVisible(on_project)
|
self._header.setVisible(on_project)
|
||||||
self._hint.setVisible(on_project)
|
# ...and the explanation only while there is nothing to explain against:
|
||||||
|
# the drawing heads a populated screen with the title alone.
|
||||||
|
self._hint.setVisible(on_project and self.project_list.count() == 0)
|
||||||
narrow = getattr(self, "_is_narrow", False)
|
narrow = getattr(self, "_is_narrow", False)
|
||||||
if self._sidebar is not None:
|
if self._sidebar is not None:
|
||||||
self._sidebar.setVisible(on_cowork)
|
self._sidebar.setVisible(on_cowork)
|
||||||
@@ -546,6 +552,9 @@ class WorkspaceTab(QWidget):
|
|||||||
row_to_select = i
|
row_to_select = i
|
||||||
self.project_list.blockSignals(False)
|
self.project_list.blockSignals(False)
|
||||||
self.project_list.setCurrentRow(row_to_select)
|
self.project_list.setCurrentRow(row_to_select)
|
||||||
|
# The drawing heads a populated screen with the title alone; the
|
||||||
|
# explanation is what an EMPTY one says instead of showing nothing.
|
||||||
|
self._hint.setVisible(self.project_list.count() == 0)
|
||||||
self._load_current()
|
self._load_current()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
Reference in New Issue
Block a user