feat: add workspace change review for agent runs (#3945)

* feat: add workspace change review

* chore: format workspace change files

* fix: optimize workspace change summaries

* style: refine workspace change badge scale

* fix: restore workspace change user context import

* fix(frontend): gate workspace change badge to assistant messages

Only pass run_id to assistant MessageListItems so the workspace-change
badge can never render under a user's prompt, which carries the same
run_id. Assert single badge render in the E2E flow.

* fix(workspace-changes): address review feedback on diff parsing and badge

- Restrict unified-diff header detection to "+++ "/"--- " (trailing space)
  in both backend _count_diff_lines and frontend getWorkspaceChangeLineClass
  so content lines beginning with +++/--- are counted/styled correctly
- Gate WorkspaceChangeBadge to ai messages so tool messages folded into an
  assistant group don't render a duplicate badge
- Add regression tests for both diff-classification fixes
- Apply prettier formatting to workspace-changes files flagged by CI

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Ryker_Feng 2026-07-05 15:38:01 +08:00 committed by GitHub
parent f6a910dec9
commit 00161811bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 2194 additions and 10 deletions

View file

@ -702,6 +702,8 @@ DeerFlow doesn't just *talk* about doing things. It has its own computer.
Each task gets its own execution environment with a full filesystem view — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It can view images and, when configured safely, execute shell commands.
After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes. Large, binary, or sensitive-looking files are shown as metadata only.
With `AioSandboxProvider`, shell execution runs inside isolated containers. With `LocalSandboxProvider`, file tools still map to per-thread directories on the host, but host `bash` is disabled by default because it is not a secure isolation boundary. Re-enable host bash only for fully trusted local workflows. Host bash commands have a wall-clock timeout, and long-lived processes should be started in the background with output redirected to a workspace log.
This is the difference between a chatbot with tool access and an agent with an actual execution environment.

View file

@ -309,12 +309,20 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; `GET /goal`, `PUT /goal`, `DELETE /goal` - read, set, and clear the active thread goal; unexpected failures are logged server-side and return a generic 500 detail |
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`<think>...</think>`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `POST /regenerate/prepare` - prepare clean input + checkpoint metadata for regenerating the latest assistant answer; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /{rid}/workspace-changes` - workspace/output file change summary and optional diffs; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **GitHub Webhooks** (`/api/webhooks/github`) | `POST /` - receive GitHub App / repo webhook deliveries. Verifies `X-Hub-Signature-256` against `GITHUB_WEBHOOK_SECRET`; exempt from auth + CSRF because authenticity is enforced by HMAC. The route is fail-closed: mounted only when `GITHUB_WEBHOOK_SECRET` is set, or when explicit dev opt-in `DEER_FLOW_ALLOW_UNVERIFIED_GITHUB_WEBHOOKS=1` is set. Recognized events include `ping`, `issues`, `issue_comment`, `pull_request`, `pull_request_review`, and `pull_request_review_comment`; unknown events return 200 with `handled=false`. Fan-out runtime failures return 503 so GitHub retries; permanent/non-retryable conditions such as `channels.github.enabled: false`, unknown events, malformed payloads, or unavailable channel service return 200 with a skipped/handled response. |
| **GitHub Event-Driven Agents** | Custom agents can declare a `github:` block in their `config.yaml` to bind to repos and event triggers. Webhook fan-out publishes one `InboundMessage` per matching binding to the channel bus; `GitHubChannel` routes those messages through `ChannelManager`. The response `dispatch` summarizes matched/fired/skipped agents. |
**Workspace change review**: `packages/harness/deerflow/workspace_changes/`
captures a pre-run and post-run snapshot of the thread-owned `workspace` and
`outputs` directories. `runtime/runs/worker.py` performs the filesystem scan via
`asyncio.to_thread` and writes a `workspace_changes` event with category
`workspace` when changes exist. Uploads are intentionally excluded. Text diffs
are size-limited; binary, large, and sensitive-looking paths are persisted as
metadata only.
**RunManager / RunStore contract**:
- `RunManager.get()` is async; direct callers must `await` it.
- When a persistent `RunStore` is configured, `get()` and `list_by_thread()` hydrate historical runs from the store. In-memory records win for the same `run_id` so task, abort, and stream-control state stays attached to active local runs.

View file

@ -26,6 +26,7 @@ from app.gateway.pagination import trim_run_message_page
from app.gateway.services import sse_consumer, start_run, wait_for_run_completion
from deerflow.runtime import RunRecord, RunStatus, serialize_channel_values_for_api
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, get_original_user_content_text, message_to_text
from deerflow.workspace_changes import get_workspace_changes_response
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/threads", tags=["runs"])
@ -742,6 +743,26 @@ async def list_run_events(
return await event_store.list_events(thread_id, run_id, event_types=types, task_id=task_id, limit=limit, after_seq=after_seq)
@router.get("/{thread_id}/runs/{run_id}/workspace-changes")
@require_permission("runs", "read", owner_check=True)
async def get_run_workspace_changes(
thread_id: str,
run_id: str,
request: Request,
include_files: bool = Query(default=True),
include_diff: bool = Query(default=True),
) -> dict:
"""Return workspace/output file changes recorded for one run."""
event_store = get_run_event_store(request)
return await get_workspace_changes_response(
event_store,
thread_id,
run_id,
include_files=include_files,
include_diff=include_diff,
)
@router.get("/{thread_id}/token-usage", response_model=ThreadTokenUsageResponse)
@require_permission("threads", "read", owner_check=True)
async def thread_token_usage(

View file

@ -766,15 +766,32 @@ async def get_thread_history(thread_id: str, body: ThreadHistoryRequest, request
if isinstance(content, dict) and content.get("type") == "ai" and "id" in content:
msg_to_run[content["id"]] = e["run_id"]
# 3. Inject the exact correct duration into each AI message
# 3. Attach the owning run_id to replayed messages.
# Raw LangGraph checkpoint messages do not carry a
# native run link. Message events are exact when
# present, but historical/runtime stores can miss
# them; the user-input message already records the
# run id for the whole turn, so use it as the
# fallback for following AI/tool messages.
current_turn_run_id = None
for msg in serialized_msgs:
if msg.get("type") == "ai":
if msg.get("type") == "human":
additional_kwargs = msg.get("additional_kwargs")
if isinstance(additional_kwargs, dict):
run_id = additional_kwargs.get("run_id")
if isinstance(run_id, str) and run_id:
current_turn_run_id = run_id
continue
if msg.get("type") in {"ai", "tool"}:
msg_id = msg.get("id")
run_id = msg_to_run.get(msg_id)
if run_id and run_id in run_durations:
if "additional_kwargs" not in msg:
msg["additional_kwargs"] = {}
msg["additional_kwargs"]["turn_duration"] = run_durations[run_id]
run_id = msg_to_run.get(msg_id) or current_turn_run_id
if run_id:
msg["run_id"] = run_id
if msg.get("type") == "ai" and run_id in run_durations:
if "additional_kwargs" not in msg:
msg["additional_kwargs"] = {}
msg["additional_kwargs"]["turn_duration"] = run_durations[run_id]
except Exception:
logger.warning("Failed to inject turn_duration for thread %s", thread_id, exc_info=True)

View file

@ -49,10 +49,12 @@ from deerflow.runtime.goal import (
)
from deerflow.runtime.serialization import serialize
from deerflow.runtime.stream_bridge import StreamBridge
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.runtime.user_context import get_effective_user_id, resolve_runtime_user_id
from deerflow.trace_context import DEERFLOW_TRACE_METADATA_KEY, get_current_trace_id, normalize_trace_id
from deerflow.tracing import inject_langfuse_metadata
from deerflow.utils.messages import message_to_text
from deerflow.workspace_changes import capture_workspace_snapshot, record_workspace_changes
from deerflow.workspace_changes.types import WorkspaceSnapshot
from .manager import RunManager, RunRecord
from .naming import resolve_root_run_name
@ -230,6 +232,8 @@ async def run_agent(
requested_modes: set[str] = set(stream_modes or ["values"])
pre_run_checkpoint_id: str | None = None
pre_run_snapshot: dict[str, Any] | None = None
pre_run_workspace_snapshot: WorkspaceSnapshot | None = None
workspace_changes_user_id: str | None = None
snapshot_capture_failed = False
llm_error_fallback_message: str | None = None
# Message ids checkpointed *before* this run started. The stream loop uses
@ -274,6 +278,16 @@ async def run_agent(
# 1. Mark running
await run_manager.set_status(run_id, RunStatus.running)
if event_store is not None:
workspace_changes_user_id = get_effective_user_id()
try:
pre_run_workspace_snapshot = await capture_workspace_snapshot(
thread_id,
user_id=workspace_changes_user_id,
)
except Exception:
logger.warning("Could not capture pre-run workspace snapshot for run %s", run_id, exc_info=True)
# Snapshot the latest pre-run checkpoint so rollback can restore it.
if checkpointer is not None:
try:
@ -551,6 +565,18 @@ async def run_agent(
if subagent_events is not None:
await subagent_events.flush()
if event_store is not None and pre_run_workspace_snapshot is not None:
try:
await record_workspace_changes(
event_store,
thread_id,
run_id,
pre_run_workspace_snapshot,
user_id=workspace_changes_user_id,
)
except Exception:
logger.warning("Failed to record workspace changes for run %s", run_id, exc_info=True)
# Flush any buffered journal events and persist completion data
if journal is not None:
try:

View file

@ -0,0 +1,33 @@
from .api import get_workspace_changes_response
from .diff import compare_snapshots, get_changed_paths
from .recorder import capture_workspace_snapshot, record_workspace_changes
from .scanner import scan_workspace_roots
from .types import (
WORKSPACE_CHANGES_EVENT_TYPE,
WORKSPACE_CHANGES_METADATA_KEY,
FileSnapshot,
WorkspaceChangeLimits,
WorkspaceChangeResult,
WorkspaceChangeSummary,
WorkspaceFileChange,
WorkspaceRoot,
WorkspaceSnapshot,
)
__all__ = [
"WORKSPACE_CHANGES_EVENT_TYPE",
"WORKSPACE_CHANGES_METADATA_KEY",
"FileSnapshot",
"WorkspaceChangeLimits",
"WorkspaceChangeResult",
"WorkspaceChangeSummary",
"WorkspaceFileChange",
"WorkspaceRoot",
"WorkspaceSnapshot",
"capture_workspace_snapshot",
"compare_snapshots",
"get_changed_paths",
"get_workspace_changes_response",
"record_workspace_changes",
"scan_workspace_roots",
]

View file

@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Any
from .types import WORKSPACE_CHANGES_EVENT_TYPE, WORKSPACE_CHANGES_METADATA_KEY
EMPTY_SUMMARY = {
"created": 0,
"modified": 0,
"deleted": 0,
"additions": 0,
"deletions": 0,
"truncated": False,
}
async def get_workspace_changes_response(
event_store: Any,
thread_id: str,
run_id: str,
*,
include_files: bool = True,
include_diff: bool = True,
) -> dict[str, Any]:
events = await event_store.list_events(
thread_id,
run_id,
event_types=[WORKSPACE_CHANGES_EVENT_TYPE],
limit=10,
)
if not events:
return _empty_response()
payload = _extract_workspace_changes_payload(events[-1])
if not isinstance(payload, dict):
return _empty_response()
response = dict(payload)
response["available"] = True
response.setdefault("summary", dict(EMPTY_SUMMARY))
if include_files:
response.setdefault("files", [])
if not include_diff:
response["files"] = [_without_diff(file) for file in response["files"]]
else:
response["files"] = []
return response
def _empty_response() -> dict[str, Any]:
return {
"available": False,
"version": 1,
"summary": dict(EMPTY_SUMMARY),
"files": [],
"limits": {},
}
def _extract_workspace_changes_payload(event: dict[str, Any]) -> Any:
metadata = event.get("metadata") or {}
if isinstance(metadata, dict) and WORKSPACE_CHANGES_METADATA_KEY in metadata:
return metadata[WORKSPACE_CHANGES_METADATA_KEY]
content = event.get("content")
if isinstance(content, dict):
return content
return None
def _without_diff(file: Any) -> Any:
if not isinstance(file, dict):
return file
sanitized = dict(file)
sanitized["diff"] = ""
return sanitized

View file

@ -0,0 +1,196 @@
from __future__ import annotations
import difflib
from .types import (
DiffUnavailableReason,
FileSnapshot,
WorkspaceChangeLimits,
WorkspaceChangeResult,
WorkspaceChangeStatus,
WorkspaceChangeSummary,
WorkspaceFileChange,
WorkspaceSnapshot,
)
def compare_snapshots(
before: WorkspaceSnapshot,
after: WorkspaceSnapshot,
*,
limits: WorkspaceChangeLimits | None = None,
) -> WorkspaceChangeResult:
resolved_limits = limits or WorkspaceChangeLimits()
all_paths = sorted(set(before.files) | set(after.files))
changes: list[WorkspaceFileChange] = []
created = modified = deleted = additions = deletions = 0
total_diff_bytes = 0
truncated = before.truncated or after.truncated
for path in all_paths:
before_file = before.files.get(path)
after_file = after.files.get(path)
if before_file and after_file and _same_file(before_file, after_file):
continue
status = _status(before_file, after_file)
if status == "created":
created += 1
elif status == "modified":
modified += 1
else:
deleted += 1
diff, line_additions, line_deletions, diff_truncated, reason = _build_diff(
path,
before_file,
after_file,
remaining_bytes=max(0, resolved_limits.max_total_diff_bytes - total_diff_bytes),
)
if diff:
total_diff_bytes += len(diff.encode("utf-8"))
if diff_truncated or reason in {"large", "truncated"}:
truncated = True
additions += line_additions
deletions += line_deletions
if len(changes) < resolved_limits.max_files:
sample = after_file or before_file
assert sample is not None
changes.append(
WorkspaceFileChange(
path=path,
root=sample.root,
status=status,
binary=bool((after_file or before_file).binary if (after_file or before_file) else False),
sensitive=bool((after_file or before_file).sensitive if (after_file or before_file) else False),
size_before=before_file.size if before_file else None,
size_after=after_file.size if after_file else None,
sha256_before=before_file.sha256 if before_file else None,
sha256_after=after_file.sha256 if after_file else None,
diff=diff,
diff_truncated=diff_truncated,
diff_unavailable_reason=reason,
additions=line_additions,
deletions=line_deletions,
)
)
else:
truncated = True
return WorkspaceChangeResult(
summary=WorkspaceChangeSummary(
created=created,
modified=modified,
deleted=deleted,
additions=additions,
deletions=deletions,
truncated=truncated,
),
files=changes,
limits=resolved_limits,
)
def get_changed_paths(before: WorkspaceSnapshot, after: WorkspaceSnapshot) -> set[str]:
changed: set[str] = set()
for path in set(before.files) | set(after.files):
before_file = before.files.get(path)
after_file = after.files.get(path)
if before_file and after_file and _same_file(before_file, after_file):
continue
changed.add(path)
return changed
def _status(
before_file: FileSnapshot | None,
after_file: FileSnapshot | None,
) -> WorkspaceChangeStatus:
if before_file is None:
return "created"
if after_file is None:
return "deleted"
return "modified"
def _same_file(before_file: FileSnapshot, after_file: FileSnapshot) -> bool:
if before_file.sha256 is not None and after_file.sha256 is not None:
return before_file.sha256 == after_file.sha256
return before_file.size == after_file.size and before_file.mtime_ns == after_file.mtime_ns
def _build_diff(
path: str,
before_file: FileSnapshot | None,
after_file: FileSnapshot | None,
*,
remaining_bytes: int,
) -> tuple[str, int, int, bool, DiffUnavailableReason | None]:
reason = _diff_unavailable_reason(before_file, after_file)
if reason is not None:
return "", 0, 0, False, reason
before_text = _snapshot_text(before_file) if before_file else ""
after_text = _snapshot_text(after_file) if after_file else ""
if before_file is not None and before_text is None:
return "", 0, 0, False, None
if after_file is not None and after_text is None:
return "", 0, 0, False, None
lines = list(
difflib.unified_diff(
before_text.splitlines(),
after_text.splitlines(),
fromfile=f"a{path}",
tofile=f"b{path}",
lineterm="",
)
)
diff = "\n".join(lines)
additions, deletions = _count_diff_lines(lines)
if len(diff.encode("utf-8")) > remaining_bytes:
return "", additions, deletions, True, "truncated"
return diff, additions, deletions, False, None
def _diff_unavailable_reason(
before_file: FileSnapshot | None,
after_file: FileSnapshot | None,
) -> DiffUnavailableReason | None:
files = [file for file in (before_file, after_file) if file is not None]
for preferred in ("sensitive", "binary", "large"):
if any(file.content_unavailable_reason == preferred for file in files):
return preferred # type: ignore[return-value]
return None
def _snapshot_text(file: FileSnapshot | None) -> str | None:
if file is None:
return ""
if file.text is not None:
return file.text
if file.text_path:
try:
with open(file.text_path, encoding="utf-8") as cached:
return cached.read()
except (OSError, UnicodeDecodeError):
return None
return None
def _count_diff_lines(lines: list[str]) -> tuple[int, int]:
additions = 0
deletions = 0
for line in lines:
# Unified-diff file headers are "+++ " / "--- " with a trailing space;
# a bare "+++"/"---" prefix would also swallow real content lines whose
# text begins with those sequences (e.g. an added line "+++foo").
if line.startswith("+++ ") or line.startswith("--- "):
continue
if line.startswith("+"):
additions += 1
elif line.startswith("-"):
deletions += 1
return additions, deletions

View file

@ -0,0 +1,111 @@
from __future__ import annotations
import asyncio
import logging
import shutil
import tempfile
from pathlib import Path
from typing import Any
from deerflow.config import get_paths
from .diff import compare_snapshots, get_changed_paths
from .scanner import scan_workspace_roots
from .types import (
WORKSPACE_CHANGES_EVENT_TYPE,
WORKSPACE_CHANGES_METADATA_KEY,
WorkspaceChangeLimits,
WorkspaceRoot,
WorkspaceSnapshot,
)
logger = logging.getLogger(__name__)
def build_thread_workspace_roots(thread_id: str, *, user_id: str | None = None) -> list[WorkspaceRoot]:
paths = get_paths()
return [
WorkspaceRoot(
name="workspace",
host_path=paths.sandbox_work_dir(thread_id, user_id=user_id),
virtual_prefix="/mnt/user-data/workspace",
),
WorkspaceRoot(
name="outputs",
host_path=paths.sandbox_outputs_dir(thread_id, user_id=user_id),
virtual_prefix="/mnt/user-data/outputs",
),
]
async def capture_workspace_snapshot(
thread_id: str,
*,
user_id: str | None = None,
limits: WorkspaceChangeLimits | None = None,
include_text: bool = True,
) -> WorkspaceSnapshot:
roots = build_thread_workspace_roots(thread_id, user_id=user_id)
text_cache_dir = Path(tempfile.mkdtemp(prefix="deerflow-workspace-changes-")) if include_text else None
try:
return await asyncio.to_thread(
scan_workspace_roots,
roots,
limits=limits,
include_text=include_text,
text_cache_dir=text_cache_dir,
)
except Exception:
if text_cache_dir is not None:
shutil.rmtree(text_cache_dir, ignore_errors=True)
raise
async def record_workspace_changes(
event_store: Any,
thread_id: str,
run_id: str,
before: WorkspaceSnapshot,
*,
user_id: str | None = None,
limits: WorkspaceChangeLimits | None = None,
) -> dict | None:
try:
roots = build_thread_workspace_roots(thread_id, user_id=user_id)
after_metadata = await asyncio.to_thread(
scan_workspace_roots,
roots,
limits=limits,
include_text=False,
)
changed_paths = get_changed_paths(before, after_metadata)
after = await asyncio.to_thread(
scan_workspace_roots,
roots,
limits=limits,
include_text=True,
text_paths=changed_paths,
)
result = compare_snapshots(before, after, limits=limits)
if not result.has_changes():
return None
payload = result.to_dict()
summary = result.summary
changed_file_count = summary.created + summary.modified + summary.deleted
content = f"{changed_file_count} file{'s' if changed_file_count != 1 else ''} changed +{summary.additions} -{summary.deletions}"
return await event_store.put(
thread_id=thread_id,
run_id=run_id,
event_type=WORKSPACE_CHANGES_EVENT_TYPE,
category="workspace",
content=content,
metadata={WORKSPACE_CHANGES_METADATA_KEY: payload},
)
finally:
_cleanup_snapshot_text_cache(before)
def _cleanup_snapshot_text_cache(snapshot: WorkspaceSnapshot) -> None:
if snapshot.text_cache_dir:
shutil.rmtree(snapshot.text_cache_dir, ignore_errors=True)

View file

@ -0,0 +1,248 @@
from __future__ import annotations
import fnmatch
import hashlib
import os
import shutil
from pathlib import Path
from .types import (
DiffUnavailableReason,
FileSnapshot,
WorkspaceChangeLimits,
WorkspaceRoot,
WorkspaceSnapshot,
)
EXCLUDED_DIR_NAMES = {
".git",
".hg",
".svn",
".cache",
".next",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
}
BINARY_EXTENSIONS = {
".7z",
".avif",
".bmp",
".class",
".db",
".dll",
".dmg",
".doc",
".docx",
".exe",
".gif",
".gz",
".ico",
".jar",
".jpeg",
".jpg",
".mov",
".mp3",
".mp4",
".o",
".pdf",
".png",
".pyc",
".so",
".tar",
".webp",
".xls",
".xlsx",
".zip",
}
SENSITIVE_PATH_PATTERNS = (
".env",
".env.*",
"*api_key*",
"*apikey*",
"*.key",
"*.pem",
"*credential*",
"*password*",
"*private_key*",
"*secret*",
"*token*",
)
SAMPLE_BYTES = 4096
def is_sensitive_workspace_path(path: str) -> bool:
normalized = path.lower()
parts = [part.lower() for part in Path(path).parts]
basename = parts[-1] if parts else normalized
for pattern in SENSITIVE_PATH_PATTERNS:
if fnmatch.fnmatch(basename, pattern) or fnmatch.fnmatch(normalized, pattern):
return True
if any(fnmatch.fnmatch(part, pattern) for part in parts):
return True
return False
def scan_workspace_roots(
roots: list[WorkspaceRoot],
*,
limits: WorkspaceChangeLimits | None = None,
include_text: bool = True,
text_paths: set[str] | None = None,
text_cache_dir: Path | None = None,
) -> WorkspaceSnapshot:
resolved_limits = limits or WorkspaceChangeLimits()
cache_dir = Path(text_cache_dir) if text_cache_dir is not None else None
if cache_dir is not None:
cache_dir.mkdir(parents=True, exist_ok=True)
files: dict[str, FileSnapshot] = {}
scanned = 0
truncated = False
for root in roots:
if not root.host_path.exists():
continue
for dirpath, dirnames, filenames in os.walk(root.host_path, followlinks=False):
dirnames[:] = [dirname for dirname in dirnames if dirname not in EXCLUDED_DIR_NAMES and not (Path(dirpath) / dirname).is_symlink()]
for filename in sorted(filenames):
if scanned >= resolved_limits.max_scanned_files:
truncated = True
return WorkspaceSnapshot(
files=files,
truncated=truncated,
text_cache_dir=str(cache_dir) if cache_dir is not None else None,
)
host_file = Path(dirpath) / filename
if host_file.is_symlink() or not host_file.is_file():
continue
snapshot = _snapshot_file(
root,
host_file,
limits=resolved_limits,
include_text=include_text,
text_paths=text_paths,
text_cache_dir=cache_dir,
)
if snapshot is not None:
files[snapshot.path] = snapshot
scanned += 1
return WorkspaceSnapshot(
files=files,
truncated=truncated,
text_cache_dir=str(cache_dir) if cache_dir is not None else None,
)
def _snapshot_file(
root: WorkspaceRoot,
host_file: Path,
*,
limits: WorkspaceChangeLimits,
include_text: bool,
text_paths: set[str] | None,
text_cache_dir: Path | None,
) -> FileSnapshot | None:
try:
stat = host_file.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
relative = host_file.relative_to(root.host_path).as_posix()
virtual_path = f"{root.virtual_prefix}/{relative}"
sensitive = is_sensitive_workspace_path(virtual_path)
except OSError:
return None
if sensitive:
return FileSnapshot(
path=virtual_path,
root=root.name,
size=size,
mtime_ns=mtime_ns,
sha256=None,
binary=False,
sensitive=True,
text=None,
content_unavailable_reason="sensitive",
)
try:
sample = host_file.read_bytes()[:SAMPLE_BYTES] if size <= SAMPLE_BYTES else _read_sample(host_file)
except OSError:
return None
binary = host_file.suffix.lower() in BINARY_EXTENSIONS or _looks_binary(sample)
sha256 = _sha256_file(host_file) if size <= limits.max_file_bytes_for_diff else None
text: str | None = None
text_path: str | None = None
reason: DiffUnavailableReason | None = None
should_include_text = include_text and (text_paths is None or virtual_path in text_paths)
if binary:
reason = "binary"
elif size > limits.max_file_bytes_for_diff:
reason = "large"
elif not should_include_text:
text = None
elif text_cache_dir is not None:
text_path = str(_cache_text_file(host_file, virtual_path, text_cache_dir))
else:
try:
text = host_file.read_text(encoding="utf-8")
except UnicodeDecodeError:
binary = True
reason = "binary"
except OSError:
return None
return FileSnapshot(
path=virtual_path,
root=root.name,
size=size,
mtime_ns=mtime_ns,
sha256=sha256,
binary=binary,
sensitive=sensitive,
text=text,
text_path=text_path,
content_unavailable_reason=reason,
)
def _cache_text_file(source: Path, virtual_path: str, cache_dir: Path) -> Path:
cache_name = hashlib.sha256(virtual_path.encode("utf-8")).hexdigest()
target = cache_dir / cache_name
shutil.copyfile(source, target)
return target
def _read_sample(path: Path) -> bytes:
with path.open("rb") as file:
return file.read(SAMPLE_BYTES)
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _looks_binary(sample: bytes) -> bool:
if b"\x00" in sample:
return True
try:
sample.decode("utf-8")
except UnicodeDecodeError:
return True
return False

View file

@ -0,0 +1,107 @@
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Literal
WORKSPACE_CHANGES_EVENT_TYPE = "workspace_changes"
WORKSPACE_CHANGES_METADATA_KEY = "workspace_changes"
WorkspaceChangeStatus = Literal["created", "modified", "deleted"]
DiffUnavailableReason = Literal["binary", "large", "sensitive", "truncated"]
@dataclass(frozen=True)
class WorkspaceChangeLimits:
max_files: int = 200
max_scanned_files: int = 2000
max_file_bytes_for_diff: int = 256 * 1024
max_total_diff_bytes: int = 1024 * 1024
def to_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class WorkspaceRoot:
name: str
host_path: Path
virtual_prefix: str
def __post_init__(self) -> None:
object.__setattr__(self, "host_path", Path(self.host_path))
object.__setattr__(self, "virtual_prefix", self.virtual_prefix.rstrip("/"))
@dataclass(frozen=True)
class FileSnapshot:
path: str
root: str
size: int
mtime_ns: int
sha256: str | None
binary: bool = False
sensitive: bool = False
text: str | None = None
text_path: str | None = None
content_unavailable_reason: DiffUnavailableReason | None = None
@dataclass(frozen=True)
class WorkspaceSnapshot:
files: dict[str, FileSnapshot] = field(default_factory=dict)
truncated: bool = False
text_cache_dir: str | None = None
@dataclass(frozen=True)
class WorkspaceFileChange:
path: str
root: str
status: WorkspaceChangeStatus
binary: bool
sensitive: bool
size_before: int | None
size_after: int | None
sha256_before: str | None
sha256_after: str | None
diff: str = ""
diff_truncated: bool = False
diff_unavailable_reason: DiffUnavailableReason | None = None
additions: int = 0
deletions: int = 0
def to_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class WorkspaceChangeSummary:
created: int = 0
modified: int = 0
deleted: int = 0
additions: int = 0
deletions: int = 0
truncated: bool = False
def to_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class WorkspaceChangeResult:
summary: WorkspaceChangeSummary
files: list[WorkspaceFileChange]
limits: WorkspaceChangeLimits = field(default_factory=WorkspaceChangeLimits)
version: int = 1
def has_changes(self) -> bool:
return bool(self.summary.created or self.summary.modified or self.summary.deleted or self.summary.additions or self.summary.deletions)
def to_dict(self) -> dict:
return {
"version": self.version,
"summary": self.summary.to_dict(),
"files": [change.to_dict() for change in self.files],
"limits": self.limits.to_dict(),
}

View file

@ -0,0 +1,445 @@
from __future__ import annotations
from pathlib import Path
import pytest
from deerflow.config.paths import Paths
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.worker import RunContext, run_agent
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.workspace_changes import (
WorkspaceChangeLimits,
WorkspaceRoot,
capture_workspace_snapshot,
compare_snapshots,
record_workspace_changes,
scan_workspace_roots,
)
from deerflow.workspace_changes.api import get_workspace_changes_response
from deerflow.workspace_changes.scanner import is_sensitive_workspace_path
def _roots(tmp_path):
workspace = tmp_path / "workspace"
outputs = tmp_path / "outputs"
workspace.mkdir()
outputs.mkdir()
return [
WorkspaceRoot(
name="workspace",
host_path=workspace,
virtual_prefix="/mnt/user-data/workspace",
),
WorkspaceRoot(
name="outputs",
host_path=outputs,
virtual_prefix="/mnt/user-data/outputs",
),
]
def test_compare_snapshots_reports_text_file_changes(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
outputs = roots[1].host_path
(workspace / "draft.md").write_text("alpha\nbeta\n", encoding="utf-8")
(workspace / "old.txt").write_text("remove me\n", encoding="utf-8")
before = scan_workspace_roots(roots)
(workspace / "draft.md").write_text("alpha\ngamma\n", encoding="utf-8")
(workspace / "old.txt").unlink()
(outputs / "report.md").write_text("# Report\n\nReady\n", encoding="utf-8")
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
assert result.summary.created == 1
assert result.summary.modified == 1
assert result.summary.deleted == 1
assert result.summary.additions >= 3
assert result.summary.deletions >= 2
changes = {change.path: change for change in result.files}
assert changes["/mnt/user-data/workspace/draft.md"].status == "modified"
assert "-beta" in changes["/mnt/user-data/workspace/draft.md"].diff
assert "+gamma" in changes["/mnt/user-data/workspace/draft.md"].diff
assert changes["/mnt/user-data/outputs/report.md"].status == "created"
assert changes["/mnt/user-data/workspace/old.txt"].status == "deleted"
def test_count_diff_lines_ignores_only_real_headers():
from deerflow.workspace_changes.diff import _count_diff_lines
lines = [
"--- a/mnt/user-data/workspace/file.txt",
"+++ b/mnt/user-data/workspace/file.txt",
"@@ -1,2 +1,2 @@",
"-old",
"+new",
# Content lines that happen to start with +++/--- must still count.
"+++added",
"---removed",
]
additions, deletions = _count_diff_lines(lines)
assert additions == 2
assert deletions == 2
def test_scan_workspace_roots_skips_excluded_directories(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
(workspace / "visible.txt").write_text("visible", encoding="utf-8")
(workspace / "node_modules").mkdir()
(workspace / "node_modules" / "ignored.js").write_text(
"ignored",
encoding="utf-8",
)
snapshot = scan_workspace_roots(roots)
assert "/mnt/user-data/workspace/visible.txt" in snapshot.files
assert "/mnt/user-data/workspace/node_modules/ignored.js" not in snapshot.files
def test_scan_workspace_roots_can_skip_text_loading(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
(workspace / "visible.txt").write_text("visible", encoding="utf-8")
snapshot = scan_workspace_roots(roots, include_text=False)
file = snapshot.files["/mnt/user-data/workspace/visible.txt"]
assert file.sha256 is not None
assert file.text is None
assert file.content_unavailable_reason is None
def test_sensitive_workspace_path_covers_common_secret_names():
for filename in (
"password.txt",
"api_key.txt",
"apikey",
"private_key.json",
):
assert is_sensitive_workspace_path(f"/mnt/user-data/workspace/{filename}")
def test_compare_snapshots_hides_sensitive_and_binary_file_content(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
before = scan_workspace_roots(roots)
(workspace / ".env").write_text("SECRET_TOKEN=abc\n", encoding="utf-8")
(workspace / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00binary")
after = scan_workspace_roots(roots)
result = compare_snapshots(before, after)
changes = {change.path: change for change in result.files}
env_change = changes["/mnt/user-data/workspace/.env"]
assert env_change.sensitive is True
assert env_change.sha256_before is None
assert env_change.sha256_after is None
assert env_change.diff == ""
assert env_change.diff_unavailable_reason == "sensitive"
binary_change = changes["/mnt/user-data/workspace/image.png"]
assert binary_change.binary is True
assert binary_change.diff == ""
assert binary_change.diff_unavailable_reason == "binary"
def test_compare_snapshots_truncates_large_text_diffs(tmp_path):
roots = _roots(tmp_path)
workspace = roots[0].host_path
before = scan_workspace_roots(roots)
(workspace / "large.txt").write_text("0123456789\n" * 20, encoding="utf-8")
after = scan_workspace_roots(
roots,
limits=WorkspaceChangeLimits(max_file_bytes_for_diff=32),
)
result = compare_snapshots(before, after)
change = result.files[0]
assert change.path == "/mnt/user-data/workspace/large.txt"
assert change.sha256_before is None
assert change.sha256_after is None
assert change.diff == ""
assert change.diff_unavailable_reason == "large"
assert result.summary.truncated is True
@pytest.mark.asyncio
async def test_workspace_changes_response_returns_summary_only_and_full_payload():
store = MemoryRunEventStore()
payload = {
"version": 1,
"summary": {
"created": 1,
"modified": 0,
"deleted": 0,
"additions": 2,
"deletions": 0,
"truncated": False,
},
"files": [
{
"path": "/mnt/user-data/outputs/report.md",
"root": "outputs",
"status": "created",
"binary": False,
"sensitive": False,
"size_before": None,
"size_after": 12,
"sha256_before": None,
"sha256_after": "abc",
"diff": "+hello",
"diff_truncated": False,
"diff_unavailable_reason": None,
"additions": 1,
"deletions": 0,
}
],
"limits": {
"max_files": 200,
"max_file_bytes_for_diff": 262144,
"max_total_diff_bytes": 1048576,
},
}
await store.put(
thread_id="thread-1",
run_id="run-1",
event_type="workspace_changes",
category="workspace",
content="1 file changed +2 -0",
metadata={"workspace_changes": payload},
)
summary = await get_workspace_changes_response(
store,
"thread-1",
"run-1",
include_files=False,
)
metadata_only = await get_workspace_changes_response(
store,
"thread-1",
"run-1",
include_files=True,
include_diff=False,
)
full = await get_workspace_changes_response(
store,
"thread-1",
"run-1",
include_files=True,
)
assert summary["available"] is True
assert summary["summary"]["created"] == 1
assert summary["files"] == []
assert metadata_only["files"][0]["path"] == "/mnt/user-data/outputs/report.md"
assert metadata_only["files"][0]["diff"] == ""
assert full["files"][0]["diff"] == "+hello"
@pytest.mark.asyncio
async def test_workspace_changes_response_is_empty_when_no_event_exists():
response = await get_workspace_changes_response(
MemoryRunEventStore(),
"thread-1",
"run-1",
)
assert response["available"] is False
assert response["summary"] == {
"created": 0,
"modified": 0,
"deleted": 0,
"additions": 0,
"deletions": 0,
"truncated": False,
}
assert response["files"] == []
@pytest.mark.anyio
async def test_run_agent_records_workspace_changes_event(tmp_path, monkeypatch):
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", Paths(tmp_path))
event_store = MemoryRunEventStore()
run_manager = RunManager()
record = await run_manager.create("thread-1")
user_id = get_effective_user_id()
paths_module.get_paths().ensure_thread_dirs("thread-1", user_id=user_id)
class DummyBridge:
async def publish(self, run_id, event, data):
return None
async def publish_end(self, run_id):
return None
async def cleanup(self, run_id, delay):
return None
class DummyAgent:
async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False):
workspace = paths_module.get_paths().sandbox_work_dir("thread-1", user_id=user_id)
(workspace / "report.md").write_text("# Report\n\nReady\n", encoding="utf-8")
yield {"messages": []}
def factory(*, config):
return DummyAgent()
await run_agent(
DummyBridge(),
run_manager,
record,
ctx=RunContext(checkpointer=None, event_store=event_store),
agent_factory=factory,
graph_input={},
config={},
)
events = await event_store.list_events(
"thread-1",
record.run_id,
event_types=["workspace_changes"],
)
assert len(events) == 1
payload = events[0]["metadata"]["workspace_changes"]
assert payload["summary"]["created"] == 1
assert payload["files"][0]["path"] == "/mnt/user-data/workspace/report.md"
assert "+# Report" in payload["files"][0]["diff"]
@pytest.mark.anyio
async def test_record_workspace_changes_content_uses_total_changed_count(tmp_path, monkeypatch):
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", Paths(tmp_path))
user_id = get_effective_user_id()
paths_module.get_paths().ensure_thread_dirs("thread-1", user_id=user_id)
workspace = paths_module.get_paths().sandbox_work_dir("thread-1", user_id=user_id)
(workspace / "unchanged.txt").write_text("keep me\n", encoding="utf-8")
before = await capture_workspace_snapshot(
"thread-1",
user_id=user_id,
limits=WorkspaceChangeLimits(max_files=1),
)
(workspace / "a.txt").write_text("a\n", encoding="utf-8")
(workspace / "b.txt").write_text("b\n", encoding="utf-8")
assert before.files["/mnt/user-data/workspace/unchanged.txt"].text is None
store = MemoryRunEventStore()
await record_workspace_changes(
store,
"thread-1",
"run-1",
before,
user_id=user_id,
limits=WorkspaceChangeLimits(max_files=1),
)
events = await store.list_events("thread-1", "run-1", event_types=["workspace_changes"])
assert events[0]["content"] == "2 files changed +2 -0"
payload = events[0]["metadata"]["workspace_changes"]
assert payload["summary"]["created"] == 2
assert len(payload["files"]) == 1
@pytest.mark.anyio
async def test_record_workspace_changes_uses_cached_baseline_for_modified_diff(tmp_path, monkeypatch):
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "_paths", Paths(tmp_path))
user_id = get_effective_user_id()
paths_module.get_paths().ensure_thread_dirs("thread-1", user_id=user_id)
workspace = paths_module.get_paths().sandbox_work_dir("thread-1", user_id=user_id)
(workspace / "edit.txt").write_text("old\n", encoding="utf-8")
before = await capture_workspace_snapshot("thread-1", user_id=user_id)
assert before.files["/mnt/user-data/workspace/edit.txt"].text is None
assert before.text_cache_dir is not None
text_cache_dir = Path(before.text_cache_dir)
assert text_cache_dir.exists()
(workspace / "edit.txt").write_text("new\n", encoding="utf-8")
store = MemoryRunEventStore()
await record_workspace_changes(
store,
"thread-1",
"run-1",
before,
user_id=user_id,
)
events = await store.list_events("thread-1", "run-1", event_types=["workspace_changes"])
diff = events[0]["metadata"]["workspace_changes"]["files"][0]["diff"]
assert "-old" in diff
assert "+new" in diff
assert not text_cache_dir.exists()
@pytest.mark.anyio
async def test_workspace_changes_route_forwards_include_files_flag():
from app.gateway.routers.thread_runs import get_run_workspace_changes
calls: dict = {}
class FakeStore:
async def list_events(self, thread_id, run_id, *, event_types=None, task_id=None, limit=500, after_seq=None):
calls.update(thread_id=thread_id, run_id=run_id, event_types=event_types)
return [
{
"metadata": {
"workspace_changes": {
"version": 1,
"summary": {
"created": 1,
"modified": 0,
"deleted": 0,
"additions": 1,
"deletions": 0,
"truncated": False,
},
"files": [{"path": "/mnt/user-data/workspace/report.md", "diff": "+hello"}],
"limits": {},
}
}
}
]
class FakeState:
run_event_store = FakeStore()
class FakeApp:
state = FakeState()
class FakeRequest:
app = FakeApp()
_deerflow_test_bypass_auth = True
response = await get_run_workspace_changes(
thread_id="thread-1",
run_id="run-1",
request=FakeRequest(),
include_files=False,
include_diff=False,
)
assert response["available"] is True
assert response["files"] == []
assert calls["event_types"] == ["workspace_changes"]

View file

@ -53,7 +53,7 @@ The frontend is a stateful chat application. Users create **threads** (conversat
- `workspace/` — Chat page components (messages, artifacts, settings)
- `landing/` — Landing page sections
- `docs/` — Docs / MDX rendering components
- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `suggestions/`, `tasks/`, `todos/`, `tools/`, `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
- **`core/`** — Business logic, the heart of the app. Domains include `threads/` (creation, streaming, state), `api/` (LangGraph client singleton), `agents/` (custom agents), `auth/` (authentication), `artifacts/`, `channels/` (IM connections), `i18n/` (en-US, zh-CN), `settings/`, `memory/`, `skills/`, `messages/`, `mcp/`, `models/`, `suggestions/`, `tasks/`, `todos/`, `tools/`, `workspace-changes/` (run-scoped changed-file summaries and diff fetching), `config/`, `notification/`, `blog/`, plus rendering helpers (`rehype/`, `streamdown/`) and `utils/`.
- **`hooks/`** — Shared React hooks
- **`lib/`** — Utilities (`cn()` from clsx + tailwind-merge)
- **`content/`** — MDX content (blog posts, docs) rendered by the app

View file

@ -0,0 +1 @@
export * from "./workspace-change-badge";

View file

@ -0,0 +1,158 @@
"use client";
import { ArrowUpRightIcon, FileDiffIcon } from "lucide-react";
import { useState } from "react";
import { Sheet } from "@/components/ui/sheet";
import { useI18n } from "@/core/i18n/hooks";
import { useWorkspaceChanges } from "@/core/workspace-changes/hooks";
import {
getChangedFileCount,
sortWorkspaceChanges,
} from "@/core/workspace-changes/summary";
import type { WorkspaceFileChange } from "@/core/workspace-changes/types";
import { cn } from "@/lib/utils";
import { WorkspaceChangePanel } from "./workspace-change-panel";
export function WorkspaceChangeBadge({
threadId,
runId,
disabled,
}: {
threadId: string;
runId?: string;
disabled?: boolean;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const { data, isLoading } = useWorkspaceChanges({
threadId,
runId,
includeFiles: true,
includeDiff: false,
enabled: Boolean(runId) && !disabled,
});
if (!runId || !data?.available) {
return null;
}
const count = getChangedFileCount(data.summary);
if (count === 0) {
return null;
}
const files = sortWorkspaceChanges(data.files);
return (
<Sheet open={open} onOpenChange={setOpen}>
<div className="border-border/70 bg-muted/20 mt-3 overflow-hidden rounded-xl border">
<div className="border-border/70 flex items-center justify-between gap-3 border-b p-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="bg-background/80 flex size-10 shrink-0 items-center justify-center rounded-lg">
<FileDiffIcon className="text-muted-foreground size-4" />
</div>
<div className="min-w-0">
<div className="text-foreground text-sm font-semibold">
{t.workspaceChanges.editedTitle(count)}
</div>
<button
type="button"
className="text-muted-foreground hover:text-foreground mt-0.5 inline-flex items-center gap-1 text-xs font-medium transition-colors"
onClick={() => setOpen(true)}
>
{t.workspaceChanges.viewChanges}
<ArrowUpRightIcon className="size-3" />
</button>
</div>
</div>
<SummaryDelta
additions={data.summary.additions}
deletions={data.summary.deletions}
className="hidden text-xs font-semibold sm:inline-flex"
/>
</div>
<div className="py-1">
{isLoading && (
<div className="text-muted-foreground px-3 py-2 text-xs">
{t.workspaceChanges.loading}
</div>
)}
{!isLoading &&
files.map((file) => (
<WorkspaceChangeSummaryRow
key={`${file.status}:${file.path}`}
file={file}
/>
))}
</div>
</div>
<WorkspaceChangePanel
threadId={threadId}
runId={runId}
fallbackSummary={data.summary}
open={open}
/>
</Sheet>
);
}
function WorkspaceChangeSummaryRow({ file }: { file: WorkspaceFileChange }) {
const pathParts = formatWorkspacePath(file.path);
return (
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="min-w-0 truncate text-sm" title={file.path}>
{pathParts.dirname && (
<span className="text-muted-foreground">{pathParts.dirname}/</span>
)}
<span className="text-foreground font-medium">
{pathParts.basename}
</span>
</div>
<SummaryDelta
additions={file.additions}
deletions={file.deletions}
className="text-sm font-semibold"
/>
</div>
);
}
function SummaryDelta({
additions,
deletions,
className,
}: {
additions: number;
deletions: number;
className?: string;
}) {
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 tabular-nums",
className,
)}
>
<span className="text-emerald-500">+{additions}</span>
<span className="text-red-500">-{deletions}</span>
</span>
);
}
function formatWorkspacePath(path: string) {
const compact = path
.replace(/^\/mnt\/user-data\/workspace\//, "")
.replace(/^\/mnt\/user-data\/outputs\//, "outputs/");
const lastSlash = compact.lastIndexOf("/");
if (lastSlash < 0) {
return { dirname: "", basename: compact };
}
return {
dirname: compact.slice(0, lastSlash),
basename: compact.slice(lastSlash + 1),
};
}

View file

@ -0,0 +1,246 @@
"use client";
import {
ExternalLinkIcon,
FileDiffIcon,
FileMinusIcon,
FilePenLineIcon,
FilePlusIcon,
} from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { useWorkspaceChanges } from "@/core/workspace-changes/hooks";
import {
getChangedFileCount,
getWorkspaceChangeLineClass,
sortWorkspaceChanges,
} from "@/core/workspace-changes/summary";
import type {
DiffUnavailableReason,
WorkspaceChangeStatus,
WorkspaceChangeSummary,
WorkspaceFileChange,
} from "@/core/workspace-changes/types";
import { cn } from "@/lib/utils";
export function WorkspaceChangePanel({
threadId,
runId,
fallbackSummary,
open,
}: {
threadId: string;
runId: string;
fallbackSummary: WorkspaceChangeSummary;
open: boolean;
}) {
const { t } = useI18n();
const { data, isLoading } = useWorkspaceChanges({
threadId,
runId,
includeFiles: true,
enabled: open,
});
const changes = data ?? {
available: true,
version: 1,
summary: fallbackSummary,
files: [],
limits: {},
};
const fileCount = getChangedFileCount(changes.summary);
const files = sortWorkspaceChanges(changes.files);
return (
<SheetContent className="w-[min(92vw,900px)] gap-0 p-0 sm:max-w-[900px]">
<SheetHeader className="border-border border-b px-5 py-4">
<SheetTitle className="flex items-center gap-2 text-base">
<FileDiffIcon className="text-muted-foreground size-4" />
{t.workspaceChanges.title}
</SheetTitle>
<SheetDescription>
{t.workspaceChanges.badge(
fileCount,
changes.summary.additions,
changes.summary.deletions,
)}
{changes.summary.truncated
? ` · ${t.workspaceChanges.truncatedSummary}`
: ""}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{isLoading && (
<p className="text-muted-foreground text-sm">
{t.workspaceChanges.loading}
</p>
)}
{!isLoading && files.length === 0 && (
<p className="text-muted-foreground text-sm">
{t.workspaceChanges.noChanges}
</p>
)}
<div className="flex flex-col gap-3">
{files.map((file) => (
<WorkspaceChangeFile
key={`${file.status}:${file.path}`}
file={file}
threadId={threadId}
/>
))}
</div>
</div>
</SheetContent>
);
}
function WorkspaceChangeFile({
file,
threadId,
}: {
file: WorkspaceFileChange;
threadId: string;
}) {
const { t } = useI18n();
const hasDiff = file.diff.length > 0;
const openUrl = resolveArtifactURL(file.path, threadId);
const canOpenFile = file.status !== "deleted" && !file.sensitive;
return (
<Collapsible defaultOpen={hasDiff}>
<div className="border-border/70 bg-background rounded-lg border">
<CollapsibleTrigger className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left">
<div className="flex min-w-0 items-start gap-2">
<StatusIcon status={file.status} />
<div className="min-w-0">
<div className="text-foreground truncate font-mono text-xs">
{file.path}
</div>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span>{statusLabel(file.status, t)}</span>
{(file.additions > 0 || file.deletions > 0) && (
<span>
<span className="text-emerald-500">+{file.additions}</span>{" "}
<span className="text-red-500">-{file.deletions}</span>
</span>
)}
</div>
</div>
</div>
{canOpenFile && (
<a
href={openUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors"
title={t.workspaceChanges.openFile}
onClick={(event) => event.stopPropagation()}
>
<ExternalLinkIcon className="size-3.5" />
</a>
)}
</CollapsibleTrigger>
<CollapsibleContent>
{hasDiff ? (
<WorkspaceDiff diff={file.diff} />
) : (
<div className="border-border/70 text-muted-foreground border-t px-3 py-3 text-xs">
{unavailableLabel(file.diff_unavailable_reason, t)}
</div>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}
function WorkspaceDiff({ diff }: { diff: string }) {
return (
<pre className="border-border/70 bg-muted/30 max-h-[520px] overflow-auto border-t p-0 font-mono text-xs leading-5">
{diff.split("\n").map((line, index) => (
<div
key={`${index}:${line}`}
className={cn(
"min-w-max px-3 whitespace-pre",
diffLineClassName(line),
)}
>
{line || " "}
</div>
))}
</pre>
);
}
function StatusIcon({ status }: { status: WorkspaceChangeStatus }) {
const className = "mt-0.5 size-4 shrink-0";
if (status === "created") {
return <FilePlusIcon className={cn(className, "text-emerald-500")} />;
}
if (status === "deleted") {
return <FileMinusIcon className={cn(className, "text-red-500")} />;
}
return <FilePenLineIcon className={cn(className, "text-sky-500")} />;
}
function statusLabel(
status: WorkspaceChangeStatus,
t: ReturnType<typeof useI18n>["t"],
) {
if (status === "created") {
return t.workspaceChanges.created;
}
if (status === "deleted") {
return t.workspaceChanges.deleted;
}
return t.workspaceChanges.modified;
}
function unavailableLabel(
reason: DiffUnavailableReason | null,
t: ReturnType<typeof useI18n>["t"],
) {
if (reason === "binary") {
return t.workspaceChanges.binaryUnavailable;
}
if (reason === "large") {
return t.workspaceChanges.largeUnavailable;
}
if (reason === "sensitive") {
return t.workspaceChanges.sensitiveUnavailable;
}
if (reason === "truncated") {
return t.workspaceChanges.truncatedUnavailable;
}
return t.workspaceChanges.diffUnavailable;
}
function diffLineClassName(line: string) {
const lineClass = getWorkspaceChangeLineClass(line);
if (lineClass === "addition") {
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
}
if (lineClass === "deletion") {
return "bg-red-500/10 text-red-700 dark:text-red-300";
}
if (lineClass === "hunk") {
return "bg-sky-500/10 text-sky-700 dark:text-sky-300";
}
if (lineClass === "meta") {
return "text-muted-foreground";
}
return "text-foreground";
}

View file

@ -47,6 +47,7 @@ import { readReferenceMessageContexts } from "@/core/sidecar";
import { SafeReasoningContent } from "@/core/streamdown/components";
import { cn } from "@/lib/utils";
import { WorkspaceChangeBadge } from "../changes";
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
import { CopyButton } from "../copy-button";
import { ReferenceAttachmentSummary } from "../sidecar/reference-attachments";
@ -151,6 +152,7 @@ export function MessageListItem({
message={message}
isLoading={isLoading}
threadId={threadId}
runId={runId}
turnStartTime={turnStartTime}
/>
{!isLoading && showCopyButton && (
@ -215,12 +217,14 @@ function MessageContent_({
message,
isLoading = false,
threadId,
runId,
turnStartTime,
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
runId?: string;
turnStartTime?: number | null;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
@ -408,6 +412,13 @@ function MessageContent_({
components={components}
/>
<CitationSourcesPanel sources={citationSources} />
{message.type === "ai" && (
<WorkspaceChangeBadge
threadId={threadId}
runId={runId}
disabled={isLoading}
/>
)}
</AIElementMessageContent>
);
}

View file

@ -578,6 +578,11 @@ export function MessageList({
groupIndex === groupedMessages.length - 1
}
threadId={threadId}
runId={
group.type === "assistant"
? (msg as { run_id?: string }).run_id
: undefined
}
showCopyButton={group.type !== "assistant"}
turnStartTime={
groupIndex === groupedMessages.length - 1

View file

@ -87,6 +87,27 @@ export const enUS: Translations = {
copiedReference: (title) => `Copied ${title} reference`,
},
// Workspace Changes
workspaceChanges: {
title: "Workspace changes",
editedTitle: (count) => `Edited ${count} ${count === 1 ? "file" : "files"}`,
badge: (count, additions, deletions) =>
`${count} ${count === 1 ? "file" : "files"} changed +${additions} -${deletions}`,
viewChanges: "View changes",
created: "Created",
modified: "Modified",
deleted: "Deleted",
openFile: "Open file",
loading: "Loading workspace changes...",
noChanges: "No workspace changes recorded.",
diffUnavailable: "Diff unavailable",
binaryUnavailable: "Binary file. Diff unavailable.",
largeUnavailable: "Large file. Diff omitted.",
sensitiveUnavailable: "Sensitive path. Content hidden.",
truncatedUnavailable: "Diff omitted because the change set is too large.",
truncatedSummary: "Some changes were truncated.",
},
// Input Box
inputBox: {
placeholder: "How can I assist you today?",

View file

@ -71,6 +71,26 @@ export interface Translations {
copiedReference: (title: string) => string;
};
// Workspace Changes
workspaceChanges: {
title: string;
editedTitle: (count: number) => string;
badge: (count: number, additions: number, deletions: number) => string;
viewChanges: string;
created: string;
modified: string;
deleted: string;
openFile: string;
loading: string;
noChanges: string;
diffUnavailable: string;
binaryUnavailable: string;
largeUnavailable: string;
sensitiveUnavailable: string;
truncatedUnavailable: string;
truncatedSummary: string;
};
// Input Box
inputBox: {
placeholder: string;

View file

@ -86,6 +86,27 @@ export const zhCN: Translations = {
copiedReference: (title) => `已复制 ${title} 引用`,
},
// Workspace Changes
workspaceChanges: {
title: "工作区变更",
editedTitle: (count) => `已编辑 ${count} 个文件`,
badge: (count, additions, deletions) =>
`${count} 个文件已更改 +${additions} -${deletions}`,
viewChanges: "查看更改",
created: "新增",
modified: "修改",
deleted: "删除",
openFile: "打开文件",
loading: "正在加载工作区变更...",
noChanges: "没有记录到工作区变更。",
diffUnavailable: "无法展示 diff",
binaryUnavailable: "二进制文件,无法展示 diff。",
largeUnavailable: "文件过大,已省略 diff。",
sensitiveUnavailable: "敏感路径,已隐藏内容。",
truncatedUnavailable: "变更集过大,已省略 diff。",
truncatedSummary: "部分变更已被截断。",
},
// Input Box
inputBox: {
placeholder: "今天我能为你做些什么?",

View file

@ -0,0 +1,34 @@
import { getBackendBaseURL } from "@/core/config";
import type { WorkspaceChangesResponse } from "./types";
export async function fetchWorkspaceChanges({
threadId,
runId,
includeFiles = true,
includeDiff = true,
}: {
threadId: string;
runId: string;
includeFiles?: boolean;
includeDiff?: boolean;
}): Promise<WorkspaceChangesResponse> {
const query = new URLSearchParams({
include_files: includeFiles ? "true" : "false",
include_diff: includeDiff ? "true" : "false",
});
const response = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(
threadId,
)}/runs/${encodeURIComponent(runId)}/workspace-changes?${query}`,
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Failed to load workspace changes." }));
throw new Error(error.detail ?? "Failed to load workspace changes.");
}
return response.json();
}

View file

@ -0,0 +1,57 @@
import { useQuery } from "@tanstack/react-query";
import { fetchWorkspaceChanges } from "./api";
import type { WorkspaceChangesResponse } from "./types";
export function workspaceChangesQueryKey(
threadId: string | undefined,
runId: string | undefined,
includeFiles: boolean,
includeDiff: boolean,
) {
return [
"workspace-changes",
threadId,
runId,
includeFiles,
includeDiff,
] as const;
}
export function useWorkspaceChanges({
threadId,
runId,
includeFiles = true,
includeDiff = true,
enabled = true,
}: {
threadId?: string;
runId?: string;
includeFiles?: boolean;
includeDiff?: boolean;
enabled?: boolean;
}) {
return useQuery<WorkspaceChangesResponse>({
queryKey: workspaceChangesQueryKey(
threadId,
runId,
includeFiles,
includeDiff,
),
queryFn: () => {
if (!threadId || !runId) {
throw new Error("threadId and runId are required");
}
return fetchWorkspaceChanges({
threadId,
runId,
includeFiles,
includeDiff,
});
},
enabled: enabled && Boolean(threadId) && Boolean(runId),
retry: false,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
}

View file

@ -0,0 +1,4 @@
export * from "./api";
export * from "./hooks";
export * from "./summary";
export * from "./types";

View file

@ -0,0 +1,54 @@
import type { WorkspaceChangeSummary, WorkspaceFileChange } from "./types";
export type WorkspaceChangeLineClass =
| "addition"
| "context"
| "deletion"
| "hunk"
| "meta";
export function getChangedFileCount(summary: WorkspaceChangeSummary) {
return summary.created + summary.modified + summary.deleted;
}
export function getWorkspaceChangeBadgeLabel(summary: WorkspaceChangeSummary) {
const count = getChangedFileCount(summary);
return `${count} ${count === 1 ? "file" : "files"} changed +${summary.additions} -${summary.deletions}`;
}
export function getWorkspaceChangeLineClass(
line: string,
): WorkspaceChangeLineClass {
// Unified-diff file headers are "+++ " / "--- " with a trailing space. A bare
// "+++"/"---" prefix would also match real content lines that begin with those
// sequences (e.g. an added line "+++foo"), styling them as meta by mistake.
if (line.startsWith("+++ ") || line.startsWith("--- ")) {
return "meta";
}
if (line.startsWith("@@")) {
return "hunk";
}
if (line.startsWith("+")) {
return "addition";
}
if (line.startsWith("-")) {
return "deletion";
}
return "context";
}
export function sortWorkspaceChanges(files: WorkspaceFileChange[]) {
const statusRank = {
created: 0,
modified: 1,
deleted: 2,
} satisfies Record<WorkspaceFileChange["status"], number>;
return [...files].sort((left, right) => {
const rankDiff = statusRank[left.status] - statusRank[right.status];
if (rankDiff !== 0) {
return rankDiff;
}
return left.path.localeCompare(right.path);
});
}

View file

@ -0,0 +1,41 @@
export type WorkspaceChangeStatus = "created" | "modified" | "deleted";
export type DiffUnavailableReason =
| "binary"
| "large"
| "sensitive"
| "truncated";
export interface WorkspaceChangeSummary {
created: number;
modified: number;
deleted: number;
additions: number;
deletions: number;
truncated: boolean;
}
export interface WorkspaceFileChange {
path: string;
root: string;
status: WorkspaceChangeStatus;
binary: boolean;
sensitive: boolean;
size_before: number | null;
size_after: number | null;
sha256_before: string | null;
sha256_after: string | null;
diff: string;
diff_truncated: boolean;
diff_unavailable_reason: DiffUnavailableReason | null;
additions: number;
deletions: number;
}
export interface WorkspaceChangesResponse {
available: boolean;
version: number;
summary: WorkspaceChangeSummary;
files: WorkspaceFileChange[];
limits: Record<string, unknown>;
}

View file

@ -0,0 +1,123 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const THREAD_ID = "00000000-0000-0000-0000-000000000321";
const RUN_ID = "run-workspace-changes";
test.describe("Workspace changes", () => {
test("shows changed files badge and opens the diff panel", async ({
page,
}) => {
const includeDiffValues: string[] = [];
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Workspace changes",
updated_at: "2026-07-04T10:00:00Z",
messages: [
{
type: "human",
id: "msg-human-workspace-changes",
content: [{ type: "text", text: "Create a report" }],
run_id: RUN_ID,
},
{
type: "ai",
id: "msg-ai-workspace-changes",
content: "I updated the workspace report.",
run_id: RUN_ID,
},
],
},
],
});
await page.route(
`**/api/threads/${THREAD_ID}/runs/${RUN_ID}/workspace-changes?*`,
async (route) => {
const url = new URL(route.request().url());
const includeFiles = url.searchParams.get("include_files") !== "false";
includeDiffValues.push(url.searchParams.get("include_diff") ?? "");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
available: true,
version: 1,
summary: {
created: 1,
modified: 1,
deleted: 0,
additions: 8,
deletions: 2,
truncated: false,
},
files: includeFiles
? [
{
path: "/mnt/user-data/outputs/report.md",
root: "outputs",
status: "modified",
binary: false,
sensitive: false,
size_before: 12,
size_after: 20,
sha256_before: "before",
sha256_after: "after",
diff: "--- a/mnt/user-data/outputs/report.md\n+++ b/mnt/user-data/outputs/report.md\n@@ -1,2 +1,2 @@\n-Draft\n+Ready",
diff_truncated: false,
diff_unavailable_reason: null,
additions: 1,
deletions: 1,
},
{
path: "/mnt/user-data/workspace/notes.txt",
root: "workspace",
status: "created",
binary: false,
sensitive: false,
size_before: null,
size_after: 8,
sha256_before: null,
sha256_after: "created",
diff: "--- a/mnt/user-data/workspace/notes.txt\n+++ b/mnt/user-data/workspace/notes.txt\n@@ -0,0 +1 @@\n+Notes",
diff_truncated: false,
diff_unavailable_reason: null,
additions: 1,
deletions: 0,
},
]
: [],
limits: {},
}),
});
},
);
await page.goto(`/workspace/chats/${THREAD_ID}`);
await expect(page.getByText("Edited 2 files")).toBeVisible({
timeout: 15_000,
});
// The human prompt carries the same run_id, but the badge must only render
// under the assistant turn — never under the user's message.
await expect(page.getByText("Edited 2 files")).toHaveCount(1);
await expect(page.getByText("outputs/report.md")).toBeVisible();
await expect(page.getByText("notes.txt")).toBeVisible();
expect(includeDiffValues).toContain("false");
await page.getByRole("button", { name: "View changes" }).click();
await expect(
page.getByRole("heading", { name: /workspace changes/i }),
).toBeVisible();
await expect(
page.getByRole("button", {
name: /\/mnt\/user-data\/outputs\/report\.md/i,
}),
).toBeVisible();
expect(includeDiffValues).toContain("true");
await expect(page.getByText("+Ready")).toBeVisible();
await expect(page.getByText("-Draft")).toBeVisible();
});
});

View file

@ -0,0 +1,50 @@
import { afterEach, expect, test, rs } from "@rstest/core";
afterEach(() => {
rs.unstubAllGlobals();
});
test("fetchWorkspaceChanges can request file metadata without diffs", async () => {
let requestedUrl = "";
const fetchMock = rs.fn(async (input: RequestInfo | URL) => {
if (typeof input === "string") {
requestedUrl = input;
} else if (input instanceof URL) {
requestedUrl = input.toString();
} else {
requestedUrl = input.url;
}
return new Response(
JSON.stringify({
available: true,
version: 1,
summary: {
created: 1,
modified: 0,
deleted: 0,
additions: 1,
deletions: 0,
truncated: false,
},
files: [],
limits: {},
}),
{ status: 200 },
);
});
rs.stubGlobal("fetch", fetchMock);
const { fetchWorkspaceChanges } =
await import("@/core/workspace-changes/api");
await fetchWorkspaceChanges({
threadId: "thread-1",
runId: "run-1",
includeFiles: true,
includeDiff: false,
});
const url = new URL(requestedUrl, "http://localhost");
expect(url.searchParams.get("include_files")).toBe("true");
expect(url.searchParams.get("include_diff")).toBe("false");
});

View file

@ -0,0 +1,49 @@
import { describe, expect, test } from "@rstest/core";
import {
getChangedFileCount,
getWorkspaceChangeBadgeLabel,
getWorkspaceChangeLineClass,
} from "@/core/workspace-changes/summary";
import type { WorkspaceChangesResponse } from "@/core/workspace-changes/types";
const changes: WorkspaceChangesResponse = {
available: true,
version: 1,
summary: {
created: 1,
modified: 2,
deleted: 0,
additions: 12,
deletions: 3,
truncated: false,
},
files: [],
limits: {},
};
describe("workspace change summary helpers", () => {
test("counts created, modified, and deleted files", () => {
expect(getChangedFileCount(changes.summary)).toBe(3);
});
test("formats the compact badge label", () => {
expect(getWorkspaceChangeBadgeLabel(changes.summary)).toBe(
"3 files changed +12 -3",
);
});
test("classifies unified diff lines", () => {
expect(getWorkspaceChangeLineClass("+new line")).toBe("addition");
expect(getWorkspaceChangeLineClass("-old line")).toBe("deletion");
expect(getWorkspaceChangeLineClass("@@ -1 +1 @@")).toBe("hunk");
expect(getWorkspaceChangeLineClass(" unchanged")).toBe("context");
expect(getWorkspaceChangeLineClass("+++ b/file.md")).toBe("meta");
expect(getWorkspaceChangeLineClass("--- a/file.md")).toBe("meta");
});
test("treats content lines beginning with +++/--- as add/remove, not meta", () => {
expect(getWorkspaceChangeLineClass("+++foo")).toBe("addition");
expect(getWorkspaceChangeLineClass("---bar")).toBe("deletion");
});
});