Files
cowork-local/ui/workspace_tab.py
T
Nam Pham Dinh ThanhandClaude Opus 5 d434f7b095 History is off the Cowork screen, not folded on it
I read "chevron gập pane Lịch sử — giữ" as "keep it as a strip" and left an 18px
sliver down the side. The drawing has nothing there: the body row is two columns,
transcript and files, and the relocation table settles it — History (lịch sử
chat), was "pane giữa, chỉ ở tab Cowork", now "Sidebar ▸ RECENTS", verdict "giữ,
dễ tới hơn". The line about the chevron belongs to a tally of the app's eleven
collapsible spots, not to this screen's layout.

So the panel is not on Cowork at all. "Tất cả project…" in RECENTS brings it in
at full width — it has to stay reachable, because search, filters, pin, rename
and multi-select delete exist nowhere else — and its chevron puts it away again.
Either choice survives leaving the screen and coming back.

check_cowork_screen now requires it absent on arrival and present after the
request; showing it unconditionally fails.

23/23 checkers pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:12:03 +09:00

877 lines
39 KiB
Python

"""Workspace screen — the app's home. Manages Projects (Claude-Projects style)
AND hosts, per selected project, the **Cowork** chat and **GraphRAG** views as
sub-tabs, all confined to that project's sandbox.
Left: the list of projects (create / delete, collapsible). Right: a tab strip
for the selected project —
* **Cowork** — the chat (with its per-project History sidebar).
* **GraphRAG** — the knowledge graph, locked to the project's sandbox.
* **Project** — name, description, shared **instructions** (injected into every
chat of the project), its sandbox **workspace folder**, and the project's
conversation **threads**.
The Cowork/GraphRAG widgets are created by the main window and handed in so the
whole app shares one instance of each; when they are not provided (e.g. unit
tests that exercise only project management) the screen still works as a plain
project manager.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMenu, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTabWidget,
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
)
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .icons import collapse_left_icon, icon
from .osutil import open_folder
from .widgets import CollapseStrip
class _ProjectRow(QWidget):
"""A project in the list: its name, and under it how much is in it.
The drawing gives every row a second line — "2 đoạn chat · 3 task" — which
is the only thing on this screen that says a project holds anything at all.
"""
def __init__(self, name: str, counts: str):
super().__init__()
lay = QVBoxLayout(self)
lay.setContentsMargins(6, 4, 6, 4)
lay.setSpacing(0)
title = QLabel(name)
sub = QLabel(counts)
sub.setObjectName("hint")
lay.addWidget(title)
lay.addWidget(sub)
class WorkspaceTab(QWidget):
status_message = Signal(str)
open_chat = Signal(str, dict) # kind, conversation — open a thread in Cowork
new_chat = Signal(str) # project_id — start a new thread in this project
projects_changed = Signal() # created/edited/deleted → History regroups
subtabs_changed = Signal() # visible sub-tabs changed → left-nav children refresh
project_selected = Signal(str) # project_id — the rail's picker follows this
# ---- nav integration: the sub-tabs are driven from the left nav rail -----
def nav_subtabs(self):
"""(label, index, icon_name) for each VISIBLE sub-tab — the left nav lists
these as children under 'Workspaces'. Icons are keyed by tab index (not
label) so they're correct in every language."""
icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat",
self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder",
self._graphrag_tab_idx: "graph"}
return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"))
for i in range(self.tabs.count()) if self.tabs.isTabVisible(i)]
def nav_entries(self):
"""(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones
included.
The rail lists all five all the time and greys out the ones the project
gate is currently closing (Cowork, GraphRAG) instead of removing them —
same gate, shown rather than hidden, so the menu stops changing shape
under the user's hand. See nav_subtabs() for the visible-only view.
"""
icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat",
self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder",
self._graphrag_tab_idx: "graph"}
return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"),
self.tabs.isTabVisible(i))
for i in range(self.tabs.count())]
def subtab_available(self, index: int) -> bool:
"""False while the project gate is holding this sub-tab shut.
The rail greys those rows out, but that only guards the rail. This lets
every other route ask the same question of the same state.
"""
return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index))
def select_subtab(self, index: int) -> None:
if 0 <= index < self.tabs.count():
self.tabs.setCurrentIndex(index)
def current_subtab(self) -> int:
return self.tabs.currentIndex()
def hide_tab_bar(self) -> None:
"""Hide the in-content tab strip (the nav rail drives the sub-tabs now),
so the content area is as large as possible."""
self.tabs.tabBar().hide()
def __init__(self, ctx: AppContext, cowork=None, structure=None, sidebar=None):
super().__init__()
self.ctx = ctx
self._current_id = ""
# True once the user asks for the full History panel; until then Cowork
# opens with it folded, as the drawing lays the screen out.
self._history_opened = False
# Shared widgets embedded as per-project sub-tabs (None in unit tests
# that only drive project management).
self._cowork = cowork
self._structure = structure
self._sidebar = sidebar
root = QVBoxLayout(self)
# Title row — the drawing puts "+ Project mới" up here beside the title,
# not at the foot of the project list where it read as belonging to the
# list's own controls.
self._header = QLabel()
self._header.setStyleSheet("font-weight:700; font-size:15px;")
self._new_btn = QPushButton()
self._new_btn.setIcon(icon("plus"))
self._new_btn.setObjectName("primary")
self._new_btn.clicked.connect(self._create)
title_row = QHBoxLayout()
title_row.addWidget(self._header)
title_row.addStretch(1)
title_row.addWidget(self._new_btn)
root.addLayout(title_row)
self._hint = QLabel()
self._hint.setObjectName("hint")
self._hint.setWordWrap(True)
root.addWidget(self._hint)
self._split = QSplitter(Qt.Horizontal)
root.addWidget(self._split, 1)
# ---- left: project list (collapsible — same chevron/strip pattern
# as History and the GraphRAG Agent panel) ---------------------------
left = QWidget()
ll = QVBoxLayout(left)
ll.setContentsMargins(0, 0, 0, 0)
left_hdr = QHBoxLayout()
self._projects_hdr = QLabel()
self._projects_hdr.setObjectName("navSectionHdr")
left_hdr.addWidget(self._projects_hdr)
self._proj_collapse_btn = QPushButton()
self._proj_collapse_btn.setIcon(collapse_left_icon())
self._proj_collapse_btn.setFixedWidth(28)
self._proj_collapse_btn.clicked.connect(lambda: self._set_projects_collapsed(True))
left_hdr.addWidget(self._proj_collapse_btn)
left_hdr.addStretch(1)
ll.addLayout(left_hdr)
self.project_list = QListWidget()
self.project_list.currentItemChanged.connect(self._on_select)
ll.addWidget(self.project_list, 1)
btns = QHBoxLayout()
# Delete stays under the list it acts on. The drawing does not show it,
# but it does not show it moved either, and dropping a control is not
# something a layout pass gets to do.
self._del_btn = QPushButton()
self._del_btn.setIcon(icon("trash"))
self._del_btn.clicked.connect(self._delete)
btns.addWidget(self._del_btn, 1)
ll.addLayout(btns)
self._projects_panel = left
# Thin strip shown in place of the project list when collapsed —
# clicking it re-expands (identical affordance to History's).
self._projects_strip = CollapseStrip(tr("workspace.expand_projects_tooltip"), expand_dir="right")
self._projects_strip.clicked.connect(lambda: self._set_projects_collapsed(False))
self._projects_strip.setVisible(False)
self._projects_pane = QWidget()
ppl = QHBoxLayout(self._projects_pane)
ppl.setContentsMargins(0, 0, 0, 0)
ppl.setSpacing(0)
ppl.addWidget(self._projects_strip)
ppl.addWidget(left, 1)
self._split.addWidget(self._projects_pane)
# ---- right: per-project tabs (Project / Cowork / GraphRAG) -----------
# Project comes FIRST; Cowork + GraphRAG only appear once a project is
# actually selected (see _update_tab_visibility).
self.tabs = QTabWidget()
# A QTabWidget's minimum width is the MAXIMUM over every page, hidden
# ones included — so Co4E (the widest, ~1180px) was setting the floor for
# Project and Cowork as well, and through them for the whole window,
# which then refused to be smaller than 1453px on any screen. An explicit
# minimum overrides that: each page still gets whatever width is going,
# and the pages that are not on screen no longer vote.
self.tabs.setMinimumWidth(560)
self._project_tab_idx = self.tabs.addTab(self._build_project_tab(), tr("workspace.tab_project"))
self._cowork_tab_idx = -1
self._graphrag_tab_idx = -1
if self._cowork is not None:
cowork_page = QWidget()
cpl = QVBoxLayout(cowork_page)
cpl.setContentsMargins(0, 0, 0, 0)
cpl.addWidget(self._cowork)
self._cowork_tab_idx = self.tabs.addTab(cowork_page, tr("workspace.tab_cowork"))
# Co4E — node-graph workflow studio (built-in flows, agents, skills, a
# runner + chat). Always available (not project-gated): its workflows
# live globally under ~/.cowork_local/co4e, not inside one project.
# Placed BEFORE GraphRAG in the tab order (user request).
from .co4e_tab import Co4ETab
self._co4e = Co4ETab(self.ctx)
self._co4e_tab_idx = self.tabs.addTab(self._co4e, tr("workspace.tab_co4e"))
self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip"))
# Folder — a two-pane file explorer (tree + view/edit) placed right below
# Co4E. Always available (not project-gated); its root follows the
# selected project's workspace folder when one is chosen.
from .folder_tab import FolderTab
self._folder = FolderTab(self.ctx, cowork=self._cowork)
self._folder.status_message.connect(self.status_message)
self._folder_tab_idx = self.tabs.addTab(self._folder, tr("workspace.tab_folder"))
if self._structure is not None:
self._graphrag_tab_idx = self.tabs.addTab(self._structure, tr("workspace.tab_graphrag"))
self.tabs.currentChanged.connect(self._on_tab_changed)
# History lives in its own pane of the OUTER splitter (not nested
# inside the Cowork tab page) so it stays visible across Cowork AND
# GraphRAG, instead of disappearing whenever GraphRAG is the active
# sub-tab (QTabWidget only shows the current page's widget).
if self._sidebar is not None:
self._split.addWidget(self._sidebar)
self._wire_sidebar()
self._split.addWidget(self.tabs)
self._split.setStretchFactor(0, 0)
if self._sidebar is not None:
self._split.setStretchFactor(1, 0)
self._split.setStretchFactor(2, 1)
self._split.setSizes([260, 240, 800])
else:
self._split.setStretchFactor(1, 1)
self._split.setSizes([260, 900])
self._apply_pane_visibility()
self.refresh()
on_language_changed(self._retranslate)
self._retranslate()
# ---- project settings tab -------------------------------------------
def _build_project_tab(self) -> QWidget:
right = QWidget()
rl = QVBoxLayout(right)
rl.setContentsMargins(8, 4, 4, 4)
self.name_edit = QLineEdit()
self.desc_edit = QLineEdit()
self._name_lbl = QLabel()
self._desc_lbl = QLabel()
rl.addWidget(self._name_lbl)
rl.addWidget(self.name_edit)
rl.addWidget(self._desc_lbl)
rl.addWidget(self.desc_edit)
self._instr_lbl = QLabel()
self.instr_edit = QPlainTextEdit()
self.instr_edit.setMaximumHeight(120)
rl.addWidget(self._instr_lbl)
rl.addWidget(self.instr_edit)
self._folder_hdr = QLabel()
rl.addWidget(self._folder_hdr)
folder_row = QHBoxLayout()
# The drawing shows the path in a field, not as grey caption text. A
# 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.setIcon(icon("folder"))
self._browse_btn.clicked.connect(self._pick_folder)
self._open_btn = QPushButton()
self._open_btn.setIcon(icon("upload"))
self._open_btn.clicked.connect(self._open_workspace)
folder_row.addWidget(self.folder_lbl, 1)
folder_row.addWidget(self._browse_btn)
folder_row.addWidget(self._open_btn)
rl.addLayout(folder_row)
rl.addStretch(1) # the drawing floats Lưu project at the bottom
save_row = QHBoxLayout()
self._save_btn = QPushButton()
self._save_btn.setIcon(icon("save"))
self._save_btn.setObjectName("primary")
self._save_btn.clicked.connect(self._save)
save_row.addStretch(1)
save_row.addWidget(self._save_btn)
rl.addLayout(save_row)
# The per-project conversation-threads list ("group chat") was removed
# from here — chats live in the History sidebar + the Cowork tab. Keep
# the settings compact at the top with a stretch below.
rl.addStretch(1)
return right
# ---- embedded sidebar (History inside the Cowork tab) ---------------
def _wire_sidebar(self) -> None:
sb = self._sidebar
sb.open_chat.connect(self._on_sidebar_open)
sb.new_chat.connect(self._on_sidebar_new)
sb.collapse_requested.connect(lambda: self._on_history_fold(True))
sb.expand_requested.connect(lambda: self._on_history_fold(False))
sb.refresh_requested.connect(self._on_sidebar_refresh)
sb.history_changed.connect(self._reload_threads)
def _on_history_fold(self, collapsed: bool) -> None:
"""Its chevron closes the panel away, back to the drawn layout."""
self._history_opened = not collapsed
if collapsed:
self._sidebar.setVisible(False)
self._apply_pane_visibility()
else:
self._set_sidebar_collapsed(False)
def _set_sidebar_collapsed(self, collapsed: bool) -> None:
"""Collapse/expand History sidebar and redistribute splitter space so
the Cowork chat area fills the freed width (same pattern as
_set_projects_collapsed)."""
sb = self._sidebar
sb.set_collapsed(collapsed)
strip_w = CollapseStrip.WIDTH + 2
if sb is None:
return
# Find sidebar index in splitter
idx = self._split.indexOf(sb)
if not (0 <= idx < self._split.count()):
return
sizes = self._split.sizes()
if collapsed:
freed = sizes[idx] - strip_w
sizes[idx] = strip_w
else:
freed = 240 - sizes[idx]
sizes[idx] = 240
# Give freed width to the LAST pane (tabs/Cowork area)
if freed != 0 and len(sizes) > 1:
sizes[-1] = max(1, sizes[-1] + freed)
self._split.setSizes(sizes)
def _on_sidebar_open(self, kind: str, conv: dict) -> None:
pid = conv.get("project_id", "") or "default"
# The legacy "default"/no-project id is intentionally not a real
# Project row (Cowork itself special-cases it as global/no-knowledge —
# see chat_agent.py), so it's fine to open with nothing selected. But a
# genuinely deleted project id must NOT force the Cowork tab open,
# since _update_tab_visibility never ran for it — that would show the
# Cowork page while the tab strip still says "no project selected".
if pid not in ("", "default") and not self._select_project_row(pid):
self.status_message.emit(tr("workspace.conversation_project_missing"))
return
if self._cowork is not None:
self._cowork.load_conversation(conv)
self._show_cowork_tab()
self.open_chat.emit(kind or "cowork", conv)
def _on_sidebar_new(self, kind: str) -> None:
if self._cowork is not None:
self._cowork.new_session()
self._show_cowork_tab()
def _on_sidebar_refresh(self) -> None:
if self._cowork is not None:
self._cowork.refresh_status()
if self._sidebar is not None:
self._sidebar.refresh()
def _show_cowork_tab(self) -> None:
if self._cowork_tab_idx >= 0:
self.tabs.setCurrentIndex(self._cowork_tab_idx)
def _on_tab_changed(self, idx: int) -> None:
# Entering GraphRAG builds its (lazy) WebEngine view and scans the
# project's sandbox; entering it is what keeps startup RAM low.
if idx == self._graphrag_tab_idx and self._structure is not None:
self._structure.auto_scan_and_fit()
self._apply_pane_visibility()
def _apply_pane_visibility(self) -> None:
"""Which side panes accompany each sub-tab:
Project → project list ✓ History ✗
Cowork → project list ✗ History ✓
GraphRAG → project list ✗ History ✗
Every page other than Project/Cowork (GraphRAG) gets the full width;
switching projects is done from the Project tab (the list there is
the app's only project picker)."""
idx = self.tabs.currentIndex()
on_project = idx == self._project_tab_idx
on_cowork = self._cowork_tab_idx >= 0 and idx == self._cowork_tab_idx
self._projects_pane.setVisible(on_project)
# "Workspace — Projects" and its three-line explanation describe the
# PROJECT screen, but were drawn above every sub-tab — ~90px of vertical
# space taken from Co4E's canvas and Folder's tree on every laptop
# screen. Shown where they apply; the text itself is unchanged.
self._header.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)
if self._sidebar is not None:
# Cowork is two columns in the drawing — transcript and files — with
# no history pane and no strip where one used to be. The relocation
# table is explicit: History moves to Sidebar ▸ RECENTS, "giữ, dễ
# tới hơn". So the panel is not on this screen at all until asked
# for: "Tất cả project…" in RECENTS brings it in, since search,
# filters, pin, rename and multi-select delete live only there.
self._sidebar.setVisible(on_cowork and self._history_opened)
if on_cowork and self._history_opened:
self._sidebar.set_collapsed(False)
# QSplitter ignores hidden panes, but the freed width isn't handed to
# the remaining panes deterministically — set explicit sizes after any
# pane toggle (same lesson as _set_projects_collapsed).
total = sum(self._split.sizes()) or 1300
# A collapsed pane must be given the STRIP width here, not its open
# width: this ran after every tab change and handed History a flat
# 240px even while it was folded to an 18px strip, leaving ~220px of
# dead space beside the chat on a small screen.
strip_w = CollapseStrip.WIDTH + 2
proj_w = 0
if on_project:
proj_w = strip_w if self._projects_strip.isVisible() else 260
hist_w = 0
if on_cowork and self._sidebar is not None:
hist_w = strip_w if self._sidebar.is_collapsed() else 240
if self._split.count() >= 3:
self._split.setSizes([proj_w, hist_w, max(1, total - proj_w - hist_w)])
else:
self._split.setSizes([proj_w, max(1, total - proj_w)])
# ---- i18n ------------------------------------------------------------
def _retranslate(self) -> None:
self._header.setText(tr("workspace.header"))
self._hint.setText(tr("workspace.hint"))
self._projects_hdr.setText(tr("workspace.projects_heading").upper())
self._folder_hdr.setText(tr("workspace.folder_label"))
self._new_btn.setText(tr("workspace.new_project"))
self._del_btn.setText(tr("workspace.delete"))
self._name_lbl.setText(tr("workspace.name"))
self._desc_lbl.setText(tr("workspace.description"))
self._instr_lbl.setText(tr("workspace.instructions"))
self.instr_edit.setPlaceholderText(tr("workspace.instructions_placeholder"))
self._browse_btn.setText(tr("workspace.browse"))
self._browse_btn.setToolTip(tr("workspace.browse_tooltip"))
self._open_btn.setText(tr("workspace.open_folder"))
self._save_btn.setText(tr("workspace.save"))
self._proj_collapse_btn.setToolTip(tr("workspace.collapse_projects_tooltip"))
self._projects_strip.setToolTip(tr("workspace.expand_projects_tooltip"))
self.tabs.setTabText(self._project_tab_idx, tr("workspace.tab_project"))
if self._cowork_tab_idx >= 0:
self.tabs.setTabText(self._cowork_tab_idx, tr("workspace.tab_cowork"))
if self._co4e_tab_idx >= 0:
self.tabs.setTabText(self._co4e_tab_idx, tr("workspace.tab_co4e"))
self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip"))
if getattr(self, "_folder_tab_idx", -1) >= 0:
self.tabs.setTabText(self._folder_tab_idx, tr("workspace.tab_folder"))
if self._graphrag_tab_idx >= 0:
self.tabs.setTabText(self._graphrag_tab_idx, tr("workspace.tab_graphrag"))
# ---- project list collapse (same pattern as History / GraphRAG Agent panel) --
# Below this window width the three panes (projects 260 + history 240 +
# the sub-page, which alone wants ~1245px on Cowork) no longer fit and Qt
# clips them instead of shrinking. Measured with tools/check_responsive.py.
_NARROW = 1500
def showEvent(self, e): # noqa: N802 - Qt override
super().showEvent(e)
if getattr(self, "_narrow", None) is None:
from .widgets import narrow_guard
self._narrow = narrow_guard(self, self._NARROW, self._apply_narrow)
self._narrow.attach()
def _apply_narrow(self, narrow: bool) -> None:
"""Fold the two side panes on a small screen so the sub-page keeps its
width; unfold them when the window grows back.
Nothing becomes unreachable: both panes leave their usual collapse strip
behind, and the project picker + RECENTS in the rail cover the same
ground while they are folded.
"""
self._is_narrow = narrow
self._set_projects_collapsed(narrow)
if self._sidebar is not None:
self._set_sidebar_collapsed(narrow)
# The chat's Files pane (~300px) is the other thing that pushes Cowork
# past the window; it has the same collapse strip to come back from.
if self._cowork is not None and hasattr(self._cowork, "_set_io_collapsed"):
self._cowork._set_io_collapsed(narrow)
if not narrow:
# Re-apply the per-tab rules the two calls above just overrode.
self._apply_pane_visibility()
def _set_projects_collapsed(self, collapsed: bool) -> None:
strip_w = CollapseStrip.WIDTH + 2
self._projects_panel.setVisible(not collapsed)
self._projects_strip.setVisible(collapsed)
if collapsed:
self._projects_pane.setMaximumWidth(strip_w)
# A maximumWidth constraint alone doesn't make QSplitter hand the
# freed space to the OTHER pane(s) — it must be told explicitly
# (same fix as StructureGraphView._set_agent_collapsed), otherwise
# the tabs pane on the right stays stuck at its old (narrower)
# size. The freed width goes to the LAST pane (tabs) regardless of
# whether History occupies a middle pane or not.
sizes = self._split.sizes()
if len(sizes) >= 2:
freed = sizes[0] - strip_w
sizes[0] = strip_w
sizes[-1] = max(1, sizes[-1] + freed)
self._split.setSizes(sizes)
else:
self._projects_pane.setMaximumWidth(16777215) # QWIDGETSIZE_MAX
if self._sidebar is not None:
self._split.setSizes([260, 240, 800])
else:
self._split.setSizes([260, 900])
# ---- data ------------------------------------------------------------
def refresh_ai_models(self) -> None:
"""Reload the Folder tab's AI-edit model picker for the active provider —
called when the active provider changes so the picker never keeps a
stale model list from the old provider."""
folder = getattr(self, "_folder", None)
if folder is not None and hasattr(folder, "ai_model_combo"):
folder.refresh_ai_models()
def refresh(self) -> None:
"""Re-list projects, keeping the current selection when possible. No
auto-seed: an empty workspace stays empty (the user must create a
project before Cowork/GraphRAG appear — see _update_tab_visibility)."""
from ..core.projects import list_projects
keep = self._current_id
counts = self._project_counts()
self.project_list.blockSignals(True)
self.project_list.clear()
row_to_select = 0
for i, p in enumerate(list_projects()):
chats, tasks = counts.get(p.project_id, (0, 0))
# No text on the item: the row widget paints the name, and setting
# both drew it twice, one string ghosting the other.
item = QListWidgetItem()
item.setData(Qt.UserRole, p.project_id)
if p.description:
item.setToolTip(p.description)
self.project_list.addItem(item)
row = _ProjectRow(p.name, tr("workspace.counts", chats=chats, tasks=tasks))
item.setSizeHint(row.sizeHint())
self.project_list.setItemWidget(item, row)
if p.project_id == keep:
row_to_select = i
self.project_list.blockSignals(False)
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()
@staticmethod
def _project_counts():
"""{project_id: (chats, tasks)} — read once per refresh, not per row."""
from ..core.history import list_conversations
from ..core.tasks import list_tasks
out: dict = {}
for conv in list_conversations():
pid = conv.get("project_id") or "default"
chats, tasks = out.get(pid, (0, 0))
out[pid] = (chats + 1, tasks)
for task in list_tasks():
pid = task.get("project_id") or "default"
chats, tasks = out.get(pid, (0, 0))
out[pid] = (chats, tasks + 1)
return out
def _selected_id(self) -> str:
item = self.project_list.currentItem()
return item.data(Qt.UserRole) if item else ""
def _select_project_row(self, project_id: str) -> bool:
for i in range(self.project_list.count()):
if self.project_list.item(i).data(Qt.UserRole) == project_id:
self.project_list.setCurrentRow(i)
return True
return False
def _on_select(self, *_a) -> None:
self._load_current()
def _load_current(self) -> None:
from ..core.projects import load_project
pid = self._selected_id()
self._current_id = pid
# Deactivate the Cowork/GraphRAG panes while (re)loading — creating a
# project or switching to another one re-binds their sandbox/history
# underneath them, so they must not look interactive mid-transition.
self._set_tabs_busy(True)
try:
project = load_project(pid) if pid else None
# Route conversation history INTO the project's workspace folder so
# sharing that folder shares the history (another machine can view +
# continue). No project → global history dir (attribute cleared).
if project is not None:
self.ctx.config._project_history_dir = project.workspace_dir() / ".cowork_history"
else:
self.ctx.config._project_history_dir = None
if project is not None:
self.name_edit.setText(project.name)
self.desc_edit.setText(project.description)
self.instr_edit.setPlainText(project.instructions)
self.folder_lbl.setText(str(project.workspace_dir()))
self._del_btn.setEnabled(True)
self._reload_threads()
if getattr(self, "_folder", None) is not None:
self._folder.set_root(str(project.workspace_dir()))
else:
self.name_edit.clear()
self.desc_edit.clear()
self.instr_edit.clear()
self.folder_lbl.setText("")
self._del_btn.setEnabled(False)
# Show Cowork/GraphRAG ONLY when a project is actually selected.
self._update_tab_visibility(project is not None)
# Bind the embedded Cowork/GraphRAG/History to this project's sandbox.
self._bind_project(pid)
finally:
self._set_tabs_busy(False)
# The rail's project picker mirrors this selection — it is a second view
# of the same state, never a second source of truth.
self.project_selected.emit(pid)
# ---- rail integration -------------------------------------------------
def project_choices(self):
"""(name, project_id) for the rail picker, in the list's own order.
Read from the store, which is what the list is built from — reading
item.text() coupled the picker to how a row happens to be drawn, and
when rows became widgets the picker went blank: every project showed
as a bare folder glyph with no name.
"""
from ..core.projects import list_projects
return [(p.name, p.project_id) for p in list_projects()]
def selected_project_id(self) -> str:
return self._selected_id()
def choose_project(self, project_id: str) -> bool:
"""Select a project by id — the same path the list row takes."""
return self._select_project_row(project_id)
def recent_threads(self, limit: int = 5):
"""The active project's most recent conversations, newest first.
Scoped to the project on purpose: history is stored inside the project's
own folder (config.history_dir() follows the selection) and the History
pane groups by project. A flat, cross-project recents list would quietly
drop that scoping.
"""
from ..core.history import list_conversations
pid = self._current_id
if not pid:
return []
try:
convos = list_conversations(self.ctx.config.history_dir())
except Exception: # noqa: BLE001
return []
out = []
for meta in convos:
if (meta.get("project_id", "") or "default") != pid:
continue
out.append({
"title": meta.get("title") or tr("sidebar.empty"),
"path": str(meta["path"]),
"kind": meta.get("kind", "") or "cowork",
"pinned": bool(meta.get("pinned", False)),
"session_id": meta.get("session_id", ""),
})
if len(out) >= limit:
break
return out
def open_thread(self, path: str, kind: str = "cowork") -> bool:
"""Open a conversation by file path — the same route the History pane's
own click takes (load_conversation → _on_sidebar_open)."""
from ..core.history import load_conversation
try:
conv = load_conversation(path)
except Exception: # noqa: BLE001
return False
self._on_sidebar_open(kind, conv)
return True
def show_history_pane(self) -> None:
"""Bring the full History panel into view (un-collapsing it if needed).
The rail's recents list is a shortcut, not a replacement: search,
filters, pin, rename, multi-select delete and the context menu all still
live in this panel.
"""
self._history_opened = True
self._show_cowork_tab()
self._sidebar.setVisible(True)
self._set_sidebar_collapsed(False)
def start_new_chat(self) -> None:
"""Start a new thread in the current project and show it.
Exactly what the History pane's own new-chat button does
(_on_sidebar_new); the rail button is a second door to the same room,
not a second implementation.
"""
self._on_sidebar_new("cowork")
def _set_tabs_busy(self, busy: bool) -> None:
for idx in (self._cowork_tab_idx, self._graphrag_tab_idx):
if idx >= 0:
widget = self.tabs.widget(idx)
if widget is not None:
widget.setEnabled(not busy)
def _update_tab_visibility(self, has_project: bool) -> None:
"""Cowork + GraphRAG tabs are visible only while a project is
selected; otherwise the screen shows just the Project (management)
tab."""
for idx in (self._cowork_tab_idx, self._graphrag_tab_idx):
if idx >= 0:
self.tabs.setTabVisible(idx, has_project)
if not has_project:
self.tabs.setCurrentIndex(self._project_tab_idx)
# setCurrentIndex doesn't fire currentChanged when the Project tab is
# already current — re-apply the pane rules explicitly.
self._apply_pane_visibility()
self.subtabs_changed.emit() # left-nav children follow visible sub-tabs
def _bind_project(self, pid: str) -> None:
# This is THE central project-switch hook — make the selected project the
# ACTIVE workspace so per-workspace modes (routing + auto-run) resolve
# against it, then refresh every surface's toggles to show its modes.
self.ctx.active_project_id = pid or "default"
self._refresh_mode_toggles()
if self._structure is not None:
self._structure.set_project(pid)
if self._sidebar is not None:
self._sidebar.set_project_filter(pid) # "" → show all (no project selected)
# Route Co4E flow output into THIS project's workspace folder (so flow
# files land in the selected workspace, like Cowork — not the config dir).
if self._co4e is not None:
self._co4e.set_project(pid)
if self._cowork is not None and pid:
# Switch to a fresh thread in the newly selected project (past
# threads are reopened from History), unless the current thread is
# already in it.
if getattr(self._cowork, "project_id", "") != pid:
self._cowork.new_session()
self._cowork.set_project(pid)
def _refresh_mode_toggles(self) -> None:
"""Re-point every surface's Off/Auto/Manual + Auto-run toggles at the
ACTIVE workspace's modes (called whenever the selected project changes)."""
targets = [
(self._cowork, "routing_toggle"),
(self._cowork, "autorun_toggle"),
(self._co4e, "co4e_routing_toggle"),
(self._folder, "ai_routing_toggle"),
]
for widget, attr in targets:
toggle = getattr(widget, attr, None) if widget is not None else None
if toggle is not None:
toggle.refresh()
def _reload_threads(self) -> None:
# The threads list was removed from the Project tab; nothing to reload.
if not hasattr(self, "threads"):
return
from ..core.history import list_conversations
self.threads.clear()
pid = self._current_id or "default"
for conv in list_conversations(self.ctx.config.history_dir()):
if conv.get("project_id", "default") != pid:
continue
label = conv["title"]
if conv.get("created"):
label += f" · {conv['created'][:16].replace('T', ' ')}"
item = QTreeWidgetItem([label])
item.setData(0, Qt.UserRole, str(conv["path"]))
self.threads.addTopLevelItem(item)
# ---- actions -----------------------------------------------------------
def _create(self) -> None:
from ..core.projects import new_project
project = new_project(tr("workspace.default_new_name"))
self._current_id = project.project_id
self.refresh()
self.projects_changed.emit()
self.name_edit.setFocus()
self.name_edit.selectAll()
def _delete(self) -> None:
from ..core.projects import delete_project, load_project
pid = self._selected_id()
project = load_project(pid) if pid else None
if project is None:
return
if QMessageBox.question(
self, tr("workspace.delete"),
tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes:
return
delete_project(pid)
self._current_id = ""
self.refresh() # empty workspace → Cowork/GraphRAG hidden until a new project
self.projects_changed.emit()
self.status_message.emit(tr("workspace.deleted", name=project.name))
def _save(self) -> None:
from ..core.projects import load_project, save_project
pid = self._current_id
project = load_project(pid) if pid else None
if project is None:
return
project.name = self.name_edit.text().strip() or project.name
project.description = self.desc_edit.text().strip()
project.instructions = self.instr_edit.toPlainText().strip()
save_project(project)
self.refresh()
self.projects_changed.emit()
self.status_message.emit(tr("workspace.saved", name=project.name))
def _pick_folder(self) -> None:
from ..core.projects import load_project, save_project
pid = self._current_id
project = load_project(pid) if pid else None
if project is None:
return
chosen = QFileDialog.getExistingDirectory(
self, tr("workspace.browse_tooltip"), str(project.workspace_dir()))
if not chosen:
return
project.output_dir = chosen
save_project(project)
self.folder_lbl.setText(chosen)
self.status_message.emit(tr("workspace.saved", name=project.name))
def _open_workspace(self) -> None:
from ..core.projects import load_project
project = load_project(self._current_id) if self._current_id else None
if project is None:
return
wd = project.workspace_dir()
wd.mkdir(parents=True, exist_ok=True)
open_folder(str(wd))