Skip to content

Refactor chat into a composable workspace shell#2011

Open
juliusmarminge wants to merge 1 commit intomainfrom
t3code/composable-chat-layout
Open

Refactor chat into a composable workspace shell#2011
juliusmarminge wants to merge 1 commit intomainfrom
t3code/composable-chat-layout

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 14, 2026

CleanShot.2026-04-13.at.19.42.25.mp4
CleanShot.2026-04-13.at.21.41.01.mp4

Summary

  • Reworked chat UI around a new workspace shell so thread surfaces, terminal surfaces, and route sync are managed through shared workspace state.
  • Moved terminal launch/open/close behavior into workspace-aware hooks and stores, reducing chat view responsibilities.
  • Expanded the command palette and keybinding handling to support workspace-specific actions and spatial layout targets.
  • Added persistence and tests for workspace documents, plus store coverage for the new workspace model.

Testing

  • Not run (not provided in the commit diff).
  • Added/updated unit tests for client persistence storage and workspace store behavior.
  • Project requirements still call for bun fmt, bun lint, bun typecheck, and bun run test before merge.

Note

Medium Risk
Moderate risk: re-routes core chat/terminal UI through a new workspace layout/store and adds new keybinding/command-palette behaviors, which could affect navigation, focus, and terminal lifecycle across the app.

Overview
Refactors chat rendering to run inside a new workspace shell that manages a split-pane/tabbed layout of thread and terminal surfaces, including pane resizing, focusing, tab close/split controls, and syncing focused surface to the URL.

Moves terminal visibility from per-thread “drawer open” state to workspace terminal surfaces, updates ChatView keyboard handling and toolbar labeling accordingly, and adds a dedicated ThreadTerminalSurface that embeds ThreadTerminalDrawer without resize handling.

Expands command palette + keybindings with a set of workspace.* commands (pane split/close, focus/move panes, move tabs, open terminal in split/tab), including a targeted “Open in split…” mode, and adds localStorage persistence for the workspace document with new unit tests covering store behavior and persistence.

Reviewed by Cursor Bugbot for commit d3d096c. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Refactor chat routes into a composable multi-pane workspace shell with split layout support

  • Introduces a workspace layer (WorkspaceShell, store.ts, types.ts) that manages a persistent split-pane layout with tabs, stored in localStorage with a 150ms debounce.
  • Chat thread and draft routes no longer render ChatView directly; instead they open/focus a workspace surface via openThreadSurface, with WorkspaceRouteSync keeping the URL and focused surface in sync.
  • Terminal rendering moves out of ChatView into a standalone ThreadTerminalSurface component managed by workspace-level surface hooks, removing per-thread terminal drawer mounting from ChatView.
  • The command palette gains workspace commands (pane split, focus, move, terminal new/split) and a workspaceTarget disposition mode for opening threads into a specific split from CommandPalette.tsx.
  • Sidebar thread context menus gain 'Open in new tab', 'Open in split right', and 'Open in split down' actions.
  • Risk: the workspace document is persisted to localStorage; users on the same browser retain split layout state across sessions, which may be surprising after this ships.
📊 Macroscope summarized d3d096c. 20 files reviewed, 4 issues evaluated, 0 issues filtered, 2 comments posted

🗂️ Filtered Issues

- Add workspace document storage and store tests
- Route terminal and command-palette actions through workspace surfaces
- Update chat layout, sidebar, and thread terminal handling
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 14, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c5add975-c545-449d-9c55-598d6bf691df

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/composable-chat-layout

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Apr 14, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Unused state variable nowTick added to ChatView
    • Removed the unused nowTick and setNowTick state declaration from ChatView.tsx as it was never referenced anywhere in the component.
  • ✅ Fixed: WorkspaceRouteSync component exported but never used
    • Deleted the entire WorkspaceRouteSync.tsx file since the component was never imported or rendered anywhere in the codebase.
  • ✅ Fixed: Unused terminalFocusRequestId state after refactor
    • Removed the unused terminalFocusRequestId and setTerminalFocusRequestId state declaration from ChatView.tsx as all consumers were removed in a prior refactor.
  • ✅ Fixed: Sidebar thread context menu navigates after split open
    • Removed the redundant router.navigate() calls after openThreadInSplit/openThreadInNewTab in the sidebar context menu handlers, which were causing the route component's useEffect to call openThreadSurface with focus-or-tab disposition and potentially refocus the surface into the wrong pane.

Create PR

Or push these changes by commenting:

@cursor push dc4ee0b932
Preview (dc4ee0b932)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -498,8 +498,6 @@
   // When set, the thread-change reset effect will open the sidebar instead of closing it.
   // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.
   const planSidebarOpenOnNextThreadRef = useRef(false);
-  const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
-  const [nowTick, setNowTick] = useState(() => Date.now());
   const [pullRequestDialogState, setPullRequestDialogState] =
     useState<PullRequestDialogState | null>(null);
   const [attachmentPreviewHandoffByMessageId, setAttachmentPreviewHandoffByMessageId] = useState<

diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -1654,28 +1654,16 @@
 
       if (clicked === "open-new-tab") {
         openThreadInNewTab(serverThreadSurfaceInput(threadRef));
-        void router.navigate({
-          to: "/$environmentId/$threadId",
-          params: buildThreadRouteParams(threadRef),
-        });
         return;
       }
 
       if (clicked === "open-split-right") {
         openThreadInSplit(serverThreadSurfaceInput(threadRef), "x");
-        void router.navigate({
-          to: "/$environmentId/$threadId",
-          params: buildThreadRouteParams(threadRef),
-        });
         return;
       }
 
       if (clicked === "open-split-down") {
         openThreadInSplit(serverThreadSurfaceInput(threadRef), "y");
-        void router.navigate({
-          to: "/$environmentId/$threadId",
-          params: buildThreadRouteParams(threadRef),
-        });
         return;
       }
 
@@ -1729,7 +1717,6 @@
       openThreadInNewTab,
       openThreadInSplit,
       project.cwd,
-      router,
     ],
   );
 

diff --git a/apps/web/src/components/workspace/WorkspaceRouteSync.tsx b/apps/web/src/components/workspace/WorkspaceRouteSync.tsx
deleted file mode 100644
--- a/apps/web/src/components/workspace/WorkspaceRouteSync.tsx
+++ /dev/null
@@ -1,130 +1,0 @@
-import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
-import { useEffect, useMemo, useRef } from "react";
-
-import { useComposerDraftStore } from "../../composerDraftStore";
-import { resolveThreadRouteTarget } from "../../threadRoutes";
-import { useFocusedWorkspaceRouteTarget, useWorkspaceStore } from "../../workspace/store";
-import type { ThreadSurfaceInput } from "../../workspace/types";
-
-function sameRouteTarget(
-  left: ReturnType<typeof resolveThreadRouteTarget>,
-  right: ReturnType<typeof resolveThreadRouteTarget>,
-): boolean {
-  if (!left && !right) {
-    return true;
-  }
-  if (!left || !right || left.kind !== right.kind) {
-    return false;
-  }
-  if (left.kind === "server" && right.kind === "server") {
-    return (
-      left.threadRef.environmentId === right.threadRef.environmentId &&
-      left.threadRef.threadId === right.threadRef.threadId
-    );
-  }
-  if (left.kind === "draft" && right.kind === "draft") {
-    return left.draftId === right.draftId;
-  }
-  return false;
-}
-
-export function WorkspaceRouteSync() {
-  const navigate = useNavigate();
-  const openThreadSurface = useWorkspaceStore((state) => state.openThreadSurface);
-  const currentRouteTarget = useParams({
-    strict: false,
-    select: (params) => resolveThreadRouteTarget(params),
-  });
-  const focusedRouteTarget = useFocusedWorkspaceRouteTarget();
-  const pathname = useLocation({
-    select: (location) => location.pathname,
-  });
-  const previousPathnameRef = useRef(pathname);
-  const draftSession = useComposerDraftStore((store) =>
-    currentRouteTarget?.kind === "draft" ? store.getDraftSession(currentRouteTarget.draftId) : null,
-  );
-  const currentRouteSurfaceInput = useMemo<ThreadSurfaceInput | null>(() => {
-    if (!currentRouteTarget) {
-      return null;
-    }
-    if (currentRouteTarget.kind === "server") {
-      return {
-        scope: "server",
-        threadRef: currentRouteTarget.threadRef,
-      };
-    }
-    if (!draftSession) {
-      return null;
-    }
-    return {
-      scope: "draft",
-      draftId: currentRouteTarget.draftId,
-      environmentId: draftSession.environmentId,
-      threadId: draftSession.threadId,
-    };
-  }, [currentRouteTarget, draftSession]);
-
-  useEffect(() => {
-    const pathnameChanged = previousPathnameRef.current !== pathname;
-    previousPathnameRef.current = pathname;
-
-    if (currentRouteTarget) {
-      if (!currentRouteSurfaceInput) {
-        return;
-      }
-
-      if (!focusedRouteTarget || pathnameChanged) {
-        openThreadSurface(currentRouteSurfaceInput, "focus-or-tab");
-        return;
-      }
-
-      if (sameRouteTarget(currentRouteTarget, focusedRouteTarget)) {
-        return;
-      }
-
-      void navigateToRouteTarget(navigate, focusedRouteTarget);
-      return;
-    }
-
-    if (!focusedRouteTarget) {
-      return;
-    }
-
-    void navigateToRouteTarget(navigate, focusedRouteTarget);
-  }, [
-    currentRouteSurfaceInput,
-    currentRouteTarget,
-    focusedRouteTarget,
-    navigate,
-    openThreadSurface,
-    pathname,
-  ]);
-
-  return null;
-}
-
-function navigateToRouteTarget(
-  navigate: ReturnType<typeof useNavigate>,
-  target: NonNullable<ReturnType<typeof resolveThreadRouteTarget>>,
-) {
-  if (target.kind === "server") {
-    return navigate({
-      to: "/$environmentId/$threadId",
-      params: {
-        environmentId: target.threadRef.environmentId,
-        threadId: target.threadRef.threadId,
-      },
-      replace: true,
-      search: {},
-    });
-  }
-
-  return navigate({
-    to: "/draft/$draftId",
-    params: {
-      draftId: target.draftId,
-    },
-    replace: true,
-    search: {},
-  });
-}
\ No newline at end of file

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit d3d096c. Configure here.

// Used by "Implement in a new thread" to carry the sidebar-open intent across navigation.
const planSidebarOpenOnNextThreadRef = useRef(false);
const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
const [nowTick, setNowTick] = useState(() => Date.now());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused state variable nowTick added to ChatView

Low Severity

The state variable nowTick and its setter setNowTick are declared but never read or called anywhere in the component. This appears to be accidentally committed scaffolding for a feature that was never wired up, adding dead code to an already large component.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d3d096c. Configure here.

replace: true,
search: {},
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WorkspaceRouteSync component exported but never used

Low Severity

WorkspaceRouteSync is a fully implemented component (130 lines) that is exported but never imported or rendered anywhere in the codebase. The route-to-workspace synchronization it would perform is instead duplicated across WorkspaceRouteFallback in WorkspaceShell.tsx and the individual route view components. This is dead code that adds maintenance burden.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d3d096c. Configure here.


const activateTerminal = useCallback(
(terminalId: string) => {
storeSetActiveTerminal(threadRef, terminalId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused terminalFocusRequestId state after refactor

Low Severity

terminalFocusRequestId state and its setter setTerminalFocusRequestId are still declared, but all setTerminalFocusRequestId calls were removed in this diff along with the PersistentThreadTerminalDrawer that consumed terminalFocusRequestId. Both the state value and setter are now dead code.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d3d096c. Configure here.

params: buildThreadRouteParams(threadRef),
});
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sidebar thread context menu navigates after split open

Medium Severity

The "Open in split right/down" and "Open in new tab" context menu handlers call openThreadInSplit/openThreadInNewTab to create a new workspace pane, then immediately navigate the route to that thread. The navigation triggers the route view's useEffect which calls openThreadSurface with "focus-or-tab" disposition, potentially refocusing the surface into the original pane instead of the newly created split, undermining the user's intent to keep both panes visible.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d3d096c. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low

const focusComposer = useCallback(() => {

The focusComposer callback at line 1290 captures composerRef in a closure with an empty dependency array []. Since composerRef is a local variable (not a React ref itself) that is conditionally assigned based on bindSharedComposerHandle and sharedComposerRef, if sharedComposerRef transitions from null to a valid ref after the initial render, focusComposer (and scheduleComposerFocus which depends on it) will permanently call .focusAtEnd() on the stale initial ref (likely localComposerRef) instead of the now-active sharedComposerRef. This means focus requests from many callbacks (e.g., handleRuntimeModeChange, onProviderModelSelect, onEnvModeChange, terminal close) would silently fail to focus the correct composer.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ChatView.tsx around line 1290:

The `focusComposer` callback at line 1290 captures `composerRef` in a closure with an empty dependency array `[]`. Since `composerRef` is a local variable (not a React ref itself) that is conditionally assigned based on `bindSharedComposerHandle` and `sharedComposerRef`, if `sharedComposerRef` transitions from `null` to a valid ref after the initial render, `focusComposer` (and `scheduleComposerFocus` which depends on it) will permanently call `.focusAtEnd()` on the stale initial ref (likely `localComposerRef`) instead of the now-active `sharedComposerRef`. This means focus requests from many callbacks (e.g., `handleRuntimeModeChange`, `onProviderModelSelect`, `onEnvModeChange`, terminal close) would silently fail to focus the correct composer.

Evidence trail:
apps/web/src/components/ChatView.tsx lines 471-475 (composerRef definition), lines 1291-1293 (focusComposer with empty deps), apps/web/src/composerHandleContext.ts (shows useComposerHandleContext returns ComposerHandleRef | null). Commit: REVIEWED_COMMIT

if (!activeThreadRef) {
return;
}
storeSetTerminalLaunchContext(activeThreadRef, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low components/ChatView.tsx:1390

When a draft thread's environment is changed via onEnvironmentChange, routeThreadRef (using route environmentId) diverges from activeThreadRef (using draftThread.environmentId). The terminal launch context is written and cleared using activeThreadRef (lines 1390, 1851, 1868) but read using routeThreadRef at line 531. This causes the clearing logic to target a different store key than the reading logic, leaving the launch context orphaned and never visible to the component that needs it.

-      storeSetTerminalLaunchContext(activeThreadRef, {
+      storeSetTerminalLaunchContext(routeThreadRef, {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/ChatView.tsx around line 1390:

When a draft thread's environment is changed via `onEnvironmentChange`, `routeThreadRef` (using route `environmentId`) diverges from `activeThreadRef` (using `draftThread.environmentId`). The terminal launch context is written and cleared using `activeThreadRef` (lines 1390, 1851, 1868) but read using `routeThreadRef` at line 531. This causes the clearing logic to target a different store key than the reading logic, leaving the launch context orphaned and never visible to the component that needs it.

Evidence trail:
- apps/web/src/components/ChatView.tsx lines 403-406: routeThreadRef definition uses route environmentId
- apps/web/src/components/ChatView.tsx lines 572-575: activeThreadRef definition uses activeThread.environmentId
- apps/web/src/components/ChatView.tsx line 564: activeThread = localDraftThread for draft threads
- apps/web/src/components/ChatView.tsx lines 1239-1250: onEnvironmentChange calls setDraftThreadContext but does not navigate
- apps/web/src/composerDraftStore.ts lines 1919-1963: setDraftThreadContext updates draftThread.environmentId
- apps/web/src/components/ChatView.tsx line 1390: storeSetTerminalLaunchContext(activeThreadRef, ...)
- apps/web/src/components/ChatView.tsx line 532: reads from scopedThreadKey(routeThreadRef)
- apps/web/src/components/ChatView.tsx lines 1851-1853, 1868-1870: storeClearTerminalLaunchContext(activeThreadRef)
- apps/web/src/terminalStateStore.ts lines 264-266: terminalThreadKey calls scopedThreadKey
- packages/client-runtime/src/scoped.ts lines 20-22: scopedRefKey produces `{environmentId}:{localId}` keys

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 14, 2026

Approvability

Verdict: Needs human review

This PR introduces a new composable workspace shell with split-pane management, terminal surfaces, and multi-window functionality — a substantial new feature rather than a simple refactor. The ~2000+ lines of new logic, new user-facing workflows, localStorage persistence, and a medium-severity open comment about sidebar context menu behavior conflicting with route sync warrant careful human review.

You can customize Macroscope's approvability policy. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant