diff --git a/app/brave_command_ids.h b/app/brave_command_ids.h
index 06da528c3f3..ab2b4b381cd 100644
--- a/app/brave_command_ids.h
+++ b/app/brave_command_ids.h
@@ -154,6 +154,10 @@
#define IDC_TOGGLE_FOCUS_MODE 56457
#define IDC_NEW_TEMPORARY_CONTAINER 56458
+// Workspaces
+#define IDC_SAVE_WORKSPACE 56459
+#define IDC_OPEN_WORKSPACE 56460
+
#define IDC_BRAVE_COMMANDS_LAST 57000
#endif // BRAVE_APP_BRAVE_COMMAND_IDS_H_
diff --git a/app/brave_generated_resources.grd b/app/brave_generated_resources.grd
index d6d25ca0937..22efd001544 100644
--- a/app/brave_generated_resources.grd
+++ b/app/brave_generated_resources.grd
@@ -253,6 +253,24 @@
Email Aliases
+
+
+
+ Save Tabs as Workspace…
+
+
+ Open Workspace…
+
+
+
+
+ Save tabs as workspace…
+
+
+ Open workspace…
+
+
+
Install and run Google Widevine
diff --git a/app/generate_command_metadata.py b/app/generate_command_metadata.py
index bb857154f35..3bfe069eded 100644
--- a/app/generate_command_metadata.py
+++ b/app/generate_command_metadata.py
@@ -243,6 +243,8 @@ EXISTING_TRANSLATIONS = {
"IDC_SHOW_BRAVE_WALLET": "IDS_SHOW_BRAVE_WALLET",
"IDC_SHOW_BRAVE_WEBCOMPAT_REPORTER": "IDS_SHOW_BRAVE_WEBCOMPAT_REPORTER",
"IDC_SHOW_EMAIL_ALIASES": "IDS_SHOW_EMAIL_ALIASES",
+ "IDC_SAVE_WORKSPACE": "IDS_SAVE_WORKSPACE",
+ "IDC_OPEN_WORKSPACE": "IDS_OPEN_WORKSPACE",
"IDC_WINDOW_CLOSE_TABS_TO_LEFT": "IDS_TAB_CXMENU_CLOSETABSTOLEFT",
"IDC_DEV_TOOLS_DEVICES": "IDS_DEV_TOOLS_DEVICES", # &Inspect devices
"IDC_DEV_TOOLS": "IDS_DEV_TOOLS", # &Developer tools
diff --git a/browser/about_flags.cc b/browser/about_flags.cc
index 9bfd095dfba..4456ff48267 100644
--- a/browser/about_flags.cc
+++ b/browser/about_flags.cc
@@ -81,6 +81,7 @@
#include "chrome/browser/flags/android/chrome_feature_list.h"
#else
#include "brave/browser/ui/views/tabs/switches.h"
+#include "brave/browser/workspaces/features.h"
#include "brave/components/commander/common/features.h"
#include "brave/components/commands/common/features.h"
#endif
@@ -795,6 +796,19 @@ constexpr flags_ui::FeatureEntry::Choice kVerticalTabCollapseDelayChoices[] = {
FEATURE_VALUE_TYPE(email_aliases::features::kEmailAliases), \
}))
+#if !BUILDFLAG(IS_ANDROID)
+#define BRAVE_WORKSPACE_FEATURE_ENTRY \
+ EXPAND_FEATURE_ENTRIES({ \
+ "brave-workspace", \
+ "Brave Workspace", \
+ "Save sets of windows and tabs into loadable workspaces.", \
+ kOsWin | kOsLinux | kOsMac, \
+ FEATURE_VALUE_TYPE(features::kWorkspaces), \
+ })
+#else
+#define BRAVE_WORKSPACE_FEATURE_ENTRY
+#endif
+
// Keep the last item empty.
#define LAST_BRAVE_FEATURE_ENTRIES_ITEM
@@ -1379,6 +1393,7 @@ constexpr flags_ui::FeatureEntry::Choice kVerticalTabCollapseDelayChoices[] = {
PSST_FEATURE_ENTRIES \
BRAVE_FORCE_POPUP_TO_BE_OPENED_IN_NEW_TAB_FEATURE_ENTRY \
EMAIL_ALIASES_FEATURE_ENTRIES \
+ BRAVE_WORKSPACE_FEATURE_ENTRY \
EXPAND_FEATURE_ENTRIES({ \
"brave-origin", \
"Enable Brave Origin", \
diff --git a/browser/browser_context_keyed_service_factories.cc b/browser/browser_context_keyed_service_factories.cc
index 2aab2a421f3..b3ba67c6e36 100644
--- a/browser/browser_context_keyed_service_factories.cc
+++ b/browser/browser_context_keyed_service_factories.cc
@@ -68,6 +68,7 @@
#include "brave/browser/ui/bookmark/bookmark_prefs_service_factory.h"
#include "brave/browser/ui/commands/accelerator_service_factory.h"
#include "brave/browser/ui/tabs/shared_pinned_tab_service_factory.h"
+#include "brave/browser/workspaces/workspace_service_factory.h"
#include "brave/components/commands/common/features.h"
#include "chrome/browser/ui/tabs/features.h"
#else
@@ -254,6 +255,10 @@ void EnsureBrowserContextKeyedServiceFactoriesBuilt() {
#endif // BUILDFLAG(ENABLE_PSST)
serp_metrics::SerpMetricsServiceFactory::GetInstance();
+
+#if !BUILDFLAG(IS_ANDROID)
+ WorkspaceServiceFactory::GetInstance();
+#endif
}
} // namespace brave
diff --git a/browser/sources.gni b/browser/sources.gni
index 2982fdb8a8e..f415fbbfc6f 100644
--- a/browser/sources.gni
+++ b/browser/sources.gni
@@ -417,6 +417,8 @@ if (!is_android) {
"//brave/browser/ui/webui/brave_new_tab_page_refresh",
"//brave/browser/ui/webui/brave_new_tab_page_refresh:mojom",
"//brave/browser/ui/webui/brave_welcome_page",
+ "//brave/browser/workspaces",
+ "//brave/browser/workspaces:impl",
"//brave/ui/webui/brave_color_change_listener",
"//chrome/browser/regional_capabilities",
"//chrome/browser/ui/thumbnails",
diff --git a/browser/ui/BUILD.gn b/browser/ui/BUILD.gn
index 5b1522b2ed8..493bbee294d 100644
--- a/browser/ui/BUILD.gn
+++ b/browser/ui/BUILD.gn
@@ -412,6 +412,7 @@ source_set("ui") {
"//brave/browser/ui/tabs:tab_strip",
"//brave/browser/ui/webui/brave_welcome_page",
"//brave/browser/ui/webui/settings/brave_account",
+ "//brave/browser/workspaces",
"//brave/common",
"//brave/components/brave_account",
"//brave/components/brave_account:features",
diff --git a/browser/ui/brave_browser_command_controller.cc b/browser/ui/brave_browser_command_controller.cc
index dca08a9a7c9..89c09addcd4 100644
--- a/browser/ui/brave_browser_command_controller.cc
+++ b/browser/ui/brave_browser_command_controller.cc
@@ -20,6 +20,9 @@
#include "brave/browser/ui/browser_commands.h"
#include "brave/browser/ui/focus_mode/focus_mode_utils.h"
#include "brave/browser/ui/sidebar/sidebar_utils.h"
+#include "brave/browser/workspaces/features.h"
+#include "brave/browser/workspaces/workspace_service.h"
+#include "brave/browser/workspaces/workspace_service_factory.h"
#include "brave/components/ai_chat/core/common/buildflags/buildflags.h"
#include "brave/components/brave_news/common/buildflags/buildflags.h"
#include "brave/components/brave_rewards/core/rewards_util.h"
@@ -369,6 +372,17 @@ void BraveBrowserCommandController::InitBraveCommandState() {
ContainersServiceFactory::GetForProfile(browser_->profile()));
#endif
+ // Reload options if person has an update in workspaces
+ if (base::FeatureList::IsEnabled(features::kWorkspaces) &&
+ browser_->is_type_normal()) {
+ UpdateCommandForWorkspace();
+ pref_change_registrar_.Add(
+ kWorkspacesMetadataPref,
+ base::BindRepeating(
+ &BraveBrowserCommandController::UpdateCommandForWorkspace,
+ base::Unretained(this)));
+ }
+
if (browser_->is_type_normal()) {
// Delete these when upstream enables by default.
UpdateCommandEnabled(IDC_READING_LIST_MENU, true);
@@ -545,6 +559,17 @@ void BraveBrowserCommandController::UpdateCommandForSplitView() {
}
}
+void BraveBrowserCommandController::UpdateCommandForWorkspace() {
+ auto* service = WorkspaceServiceFactory::GetForProfile(browser_->profile());
+
+ if (!service) {
+ return;
+ }
+
+ UpdateCommandEnabled(IDC_SAVE_WORKSPACE, true);
+ UpdateCommandEnabled(IDC_OPEN_WORKSPACE, !service->ListWorkspaces().empty());
+}
+
void BraveBrowserCommandController::UpdateCommandForBraveSync() {
UpdateCommandEnabled(IDC_SHOW_BRAVE_SYNC, true);
}
@@ -818,6 +843,18 @@ bool BraveBrowserCommandController::ExecuteBraveCommandWithDisposition(
case IDC_TOGGLE_FOCUS_MODE:
brave::ToggleFocusMode(base::to_address(browser_));
break;
+ case IDC_SAVE_WORKSPACE:
+ if (auto* svc =
+ WorkspaceServiceFactory::GetForProfile(browser_->profile())) {
+ svc->ShowSaveWorkspaceDialog();
+ }
+ break;
+ case IDC_OPEN_WORKSPACE:
+ if (auto* svc =
+ WorkspaceServiceFactory::GetForProfile(browser_->profile())) {
+ svc->ShowOpenWorkspaceDialog();
+ }
+ break;
default:
LOG(WARNING) << "Received Unimplemented Command: " << id;
break;
diff --git a/browser/ui/brave_browser_command_controller.h b/browser/ui/brave_browser_command_controller.h
index eb353c63c9f..48255cabbb5 100644
--- a/browser/ui/brave_browser_command_controller.h
+++ b/browser/ui/brave_browser_command_controller.h
@@ -104,6 +104,7 @@ class BraveBrowserCommandController : public chrome::BrowserCommandController
void UpdateCommandsForPin();
void UpdateCommandForFocusMode();
void UpdateCommandForSplitView();
+ void UpdateCommandForWorkspace();
bool ExecuteBraveCommandWithDisposition(int id,
WindowOpenDisposition disposition,
diff --git a/browser/ui/toolbar/brave_app_menu_model.cc b/browser/ui/toolbar/brave_app_menu_model.cc
index 2ecbc3227ef..3abffafe950 100644
--- a/browser/ui/toolbar/brave_app_menu_model.cc
+++ b/browser/ui/toolbar/brave_app_menu_model.cc
@@ -307,6 +307,25 @@ void BraveAppMenuModel::BuildMoreToolsSubMenu() {
need_separator = false;
}
+ // Save/Open workspaces
+ if (IsCommandIdEnabled(IDC_SAVE_WORKSPACE)) {
+ more_tools_menu_model->InsertItemWithStringIdAt(
+ next_target_index++, IDC_SAVE_WORKSPACE, IDS_SAVE_WORKSPACE);
+ need_separator = true;
+ }
+
+ if (IsCommandIdEnabled(IDC_OPEN_WORKSPACE)) {
+ more_tools_menu_model->InsertItemWithStringIdAt(
+ next_target_index++, IDC_OPEN_WORKSPACE, IDS_OPEN_WORKSPACE);
+ need_separator = true;
+ }
+
+ if (need_separator) {
+ more_tools_menu_model->InsertSeparatorAt(next_target_index++,
+ ui::NORMAL_SEPARATOR);
+ need_separator = false;
+ }
+
if (!browser()->profile()->IsOffTheRecord()) {
if (auto index =
more_tools_menu_model->GetIndexOfCommandId(IDC_NAME_WINDOW)) {
diff --git a/browser/workspaces/BUILD.gn b/browser/workspaces/BUILD.gn
new file mode 100644
index 00000000000..09c8548df62
--- /dev/null
+++ b/browser/workspaces/BUILD.gn
@@ -0,0 +1,61 @@
+# Copyright (c) 2026 The Brave Authors. All rights reserved.
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at https://mozilla.org/MPL/2.0/.
+
+source_set("impl") {
+ visibility = [
+ "//brave/browser/workspaces:*",
+ "//chrome/browser",
+ ]
+
+ sources = [
+ "features.cc",
+ "workspace_service.cc",
+ "workspace_service_factory.cc",
+ "workspace_session_utils.cc",
+ "workspace_utils.cc",
+ ]
+
+ deps = [
+ ":workspaces",
+ "//base",
+ "//chrome/browser/ui",
+ "//chrome/browser/ui/tabs:tab_strip",
+ ]
+}
+source_set("workspaces") {
+ sources = [
+ "features.h",
+ "pref_names.h",
+ "workspace_metadata.h",
+ "workspace_service.h",
+ "workspace_service_factory.h",
+ "workspace_session_utils.h",
+ "workspace_utils.h",
+ ]
+
+ public_deps = [
+ "//base",
+ "//chrome/browser/profiles:profile",
+ "//components/keyed_service/core",
+ "//components/pref_registry",
+ "//components/sessions",
+ ]
+}
+
+source_set("unit_tests") {
+ testonly = true
+ sources = [ "workspace_service_unittest.cc" ]
+ deps = [
+ ":impl",
+ ":workspaces",
+ "//base",
+ "//base/test:test_support",
+ "//chrome/test:test_support",
+ "//components/sessions",
+ "//components/sync_preferences:test_support",
+ "//content/test:test_support",
+ "//testing/gtest",
+ ]
+}
diff --git a/browser/workspaces/features.cc b/browser/workspaces/features.cc
new file mode 100644
index 00000000000..f4d3d1cbd85
--- /dev/null
+++ b/browser/workspaces/features.cc
@@ -0,0 +1,15 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#include "brave/browser/workspaces/features.h"
+
+namespace features {
+
+// Allows saving open tabs and windows as a named workspace for later restore.
+// NOTE: Being implemented in phases.
+// See https://github.com/brave/brave-browser/issues/54738
+BASE_FEATURE(kWorkspaces, base::FEATURE_DISABLED_BY_DEFAULT);
+
+} // namespace features
diff --git a/browser/workspaces/features.h b/browser/workspaces/features.h
new file mode 100644
index 00000000000..f890af8d46c
--- /dev/null
+++ b/browser/workspaces/features.h
@@ -0,0 +1,17 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#ifndef BRAVE_BROWSER_WORKSPACES_FEATURES_H_
+#define BRAVE_BROWSER_WORKSPACES_FEATURES_H_
+
+#include "base/feature_list.h"
+
+namespace features {
+
+BASE_DECLARE_FEATURE(kWorkspaces);
+
+} // namespace features
+
+#endif // BRAVE_BROWSER_WORKSPACES_FEATURES_H_
diff --git a/browser/workspaces/pref_names.h b/browser/workspaces/pref_names.h
new file mode 100644
index 00000000000..cec2b78578a
--- /dev/null
+++ b/browser/workspaces/pref_names.h
@@ -0,0 +1,12 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#ifndef BRAVE_BROWSER_WORKSPACES_PREF_NAMES_H_
+#define BRAVE_BROWSER_WORKSPACES_PREF_NAMES_H_
+
+// Profile preference key — stores a dict keyed by hash of the display name.
+inline constexpr char kWorkspacesMetadataPref[] = "brave.workspaces.metadata";
+
+#endif // BRAVE_BROWSER_WORKSPACES_PREF_NAMES_H_
diff --git a/browser/workspaces/workspace_metadata.h b/browser/workspaces/workspace_metadata.h
new file mode 100644
index 00000000000..decb3b1a31d
--- /dev/null
+++ b/browser/workspaces/workspace_metadata.h
@@ -0,0 +1,22 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_METADATA_H_
+#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_METADATA_H_
+
+#include
+
+#include "base/time/time.h"
+
+// Lightweight summary returned by ListWorkspaces(), used to populate UI.
+// The full session state is stored as Chromium session commands on disk.
+struct WorkspaceMetadata {
+ std::string name;
+ base::Time modified_at;
+ int number_of_windows = 0;
+ int number_of_tabs = 0;
+};
+
+#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_METADATA_H_
diff --git a/browser/workspaces/workspace_service.cc b/browser/workspaces/workspace_service.cc
new file mode 100644
index 00000000000..7a5ac0f458a
--- /dev/null
+++ b/browser/workspaces/workspace_service.cc
@@ -0,0 +1,141 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#include "brave/browser/workspaces/workspace_service.h"
+
+#include
+#include
+#include
+#include
+
+#include "base/functional/bind.h"
+#include "base/hash/hash.h"
+#include "base/logging.h"
+#include "base/task/bind_post_task.h"
+#include "base/task/sequenced_task_runner.h"
+#include "base/task/thread_pool.h"
+#include "base/time/time.h"
+#include "brave/browser/workspaces/workspace_session_utils.h"
+#include "brave/browser/workspaces/workspace_utils.h"
+#include "chrome/browser/profiles/profile.h"
+#include "components/prefs/pref_service.h"
+#include "components/prefs/scoped_user_pref_update.h"
+#include "components/sessions/core/session_id.h"
+#include "third_party/abseil-cpp/absl/strings/str_format.h"
+
+namespace {
+
+std::string ComputeKey(const std::string& name) {
+ return absl::StrFormat("%08x", base::PersistentHash(name));
+}
+
+} // namespace
+
+WorkspaceService::WorkspaceService(Profile& profile)
+ : profile_(profile),
+ workspaces_path_(profile.GetPath().AppendASCII("workspaces")),
+ pref_service_(*profile.GetPrefs()),
+ io_task_runner_(base::ThreadPool::CreateSequencedTaskRunner(
+ {base::MayBlock(), base::TaskPriority::USER_VISIBLE,
+ base::TaskShutdownBehavior::BLOCK_SHUTDOWN})) {}
+
+WorkspaceService::~WorkspaceService() = default;
+
+std::vector WorkspaceService::ListWorkspaces() const {
+ return ListWorkspacesFromDict(
+ pref_service_->GetDict(kWorkspacesMetadataPref));
+}
+
+void WorkspaceService::SaveWorkspaceMetadata(const WorkspaceMetadata& meta) {
+ ScopedDictPrefUpdate updated(*pref_service_, kWorkspacesMetadataPref);
+ updated->Set(ComputeKey(meta.name), WorkspaceMetadataToDictEntry(meta));
+}
+
+void WorkspaceService::RemoveWorkspaceMetadata(const std::string& name) {
+ ScopedDictPrefUpdate updated(*pref_service_, kWorkspacesMetadataPref);
+ updated->Remove(ComputeKey(name));
+}
+
+base::FilePath WorkspaceService::GetWorkspacePathForName(
+ const std::string& name) const {
+ return workspaces_path_.AppendASCII(ComputeKey(name));
+}
+
+void WorkspaceService::SaveWorkspace(const std::string& name) {
+ if (name.empty()) {
+ return;
+ }
+
+ // Collect session commands on the UI thread, then write to disk on a
+ // background task (WriteWorkspaceToDisk does blocking file I/O).
+ WorkspaceMetadata workspace{.name = name, .modified_at = base::Time::Now()};
+ std::vector> commands =
+ GenerateBrowserSessionCommandsForWorkspace(base::to_address(profile_),
+ workspace);
+ base::FilePath workspace_path = GetWorkspacePathForName(name);
+
+ auto backend = base::MakeRefCounted(
+ io_task_runner_, workspace_path, kWorkspaceSessionType,
+ /*encryptor=*/std::nullopt);
+
+ // Save metadata optimistically now; roll it back if the write fails.
+ // BindPostTask ensures on_error always runs on the UI thread whether it is
+ // invoked by AppendCommands or directly by WriteWorkspaceToDisk on a
+ // directory-creation failure.
+ SaveWorkspaceMetadata(workspace);
+
+ // "Rolling back" is just removing the metadata from the dictionary.
+ auto on_error = base::BindPostTask(
+ base::SequencedTaskRunner::GetCurrentDefault(),
+ base::BindOnce(&WorkspaceService::RemoveWorkspaceMetadata, GetWeakPtr(),
+ name));
+
+ io_task_runner_->PostTask(
+ FROM_HERE, base::BindOnce(&WriteWorkspaceToDisk, std::move(commands),
+ std::move(workspace_path), std::move(backend),
+ std::move(on_error)));
+}
+
+void WorkspaceService::RestoreWorkspace(const std::string& name) {
+ base::FilePath path = GetWorkspacePathForName(name);
+ auto backend = base::MakeRefCounted(
+ io_task_runner_, path, kWorkspaceSessionType,
+ /*encryptor=*/std::nullopt);
+
+ io_task_runner_->PostTaskAndReplyWithResult(
+ FROM_HERE,
+ base::BindOnce(&ReadWorkspaceFromDisk, std::move(path),
+ std::move(backend)),
+ base::BindOnce(&WorkspaceService::DoRestoreWorkspace, GetWeakPtr()));
+}
+
+void WorkspaceService::ShowSaveWorkspaceDialog() {
+ // TODO(https://github.com/brave/brave-browser/issues/55108)
+ SaveWorkspace("example-workspace");
+}
+
+void WorkspaceService::ShowOpenWorkspaceDialog() {
+ // TODO(https://github.com/brave/brave-browser/issues/55108)
+ RestoreWorkspace("example-workspace");
+}
+
+void WorkspaceService::DoRestoreWorkspace(
+ std::vector> commands) {
+ if (commands.empty()) {
+ DVLOG(1) << "Could not load workspace: no commands";
+ return;
+ }
+
+ RestoreBrowserSessionCommandsForWorkspace(base::to_address(profile_),
+ std::move(commands));
+}
+
+base::WeakPtr WorkspaceService::GetWeakPtr() {
+ return weak_ptr_factory_.GetWeakPtr();
+}
+
+void WorkspaceService::Shutdown() {
+ weak_ptr_factory_.InvalidateWeakPtrs();
+}
diff --git a/browser/workspaces/workspace_service.h b/browser/workspaces/workspace_service.h
new file mode 100644
index 00000000000..bd863f4a311
--- /dev/null
+++ b/browser/workspaces/workspace_service.h
@@ -0,0 +1,100 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_H_
+#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_H_
+
+#include
+#include
+#include
+
+#include "base/files/file_path.h"
+#include "base/memory/raw_ref.h"
+#include "base/memory/scoped_refptr.h"
+#include "base/memory/weak_ptr.h"
+#include "base/task/sequenced_task_runner.h"
+#include "brave/browser/workspaces/pref_names.h"
+#include "brave/browser/workspaces/workspace_metadata.h"
+#include "components/keyed_service/core/keyed_service.h"
+#include "components/sessions/core/session_command.h"
+
+class PrefService;
+class Profile;
+
+// Per-profile service that manages saving and restoring named workspaces.
+//
+// Each workspace is stored in its own subdirectory under
+// {profile_dir}/workspaces/{hashed_name}/
+//
+// Inside that subdirectory:
+// Sessions/Session_* — full browser state in Chromium's session-command
+// binary format (handled by CommandStorageBackend)
+//
+// Workspace metadata (display name, window/tab counts, creation time) is
+// stored in a profile preference rather than a per-directory info.json file.
+//
+// Threading note: All workspace file I/O runs on |io_task_runner_|, a
+// MayBlock SequencedTaskRunner created at construction time. Backends are
+// constructed on the UI thread with |io_task_runner_| as their owning
+// sequence, then all blocking I/O tasks are posted to that same runner.
+class WorkspaceService : public KeyedService {
+ public:
+ explicit WorkspaceService(Profile& profile);
+ ~WorkspaceService() override;
+
+ WorkspaceService(const WorkspaceService&) = delete;
+ WorkspaceService& operator=(const WorkspaceService&) = delete;
+
+ // Returns summary information for all saved workspaces, sorted by time
+ // modified (most-recent first). Reads from the profile preference; no disk
+ // I/O.
+ std::vector ListWorkspaces() const;
+
+ // Writes workspace metadata into the profile preference. Called on the UI
+ // thread after a successful WriteWorkspaceToDisk background task.
+ void SaveWorkspaceMetadata(const WorkspaceMetadata& meta);
+
+ // Removes the workspace metadata entry from the profile preference.
+ void RemoveWorkspaceMetadata(const std::string& name);
+
+ // Returns the directory that contains all workspace subdirectories.
+ const base::FilePath& GetWorkspacesPath() const { return workspaces_path_; }
+
+ // Returns the per-workspace subdirectory for |name|.
+ base::FilePath GetWorkspacePathForName(const std::string& name) const;
+
+ // ---- Browser-state commands (UI thread only) ----------------------------
+
+ // Serializes all open windows/tabs for this profile and writes them to disk
+ // under the given workspace name.
+ void SaveWorkspace(const std::string& name);
+
+ // Reads the named workspace from disk and opens its windows/tabs.
+ void RestoreWorkspace(const std::string& name);
+
+ // Placeholder entry points for the save/open dialogs.
+ void ShowSaveWorkspaceDialog();
+ void ShowOpenWorkspaceDialog();
+
+ base::WeakPtr GetWeakPtr();
+
+ // KeyedService:
+ void Shutdown() override;
+
+ private:
+ // Called on the UI thread with the commands read from disk by
+ // RestoreWorkspace.
+ void DoRestoreWorkspace(
+ std::vector> commands);
+
+ raw_ref profile_;
+ const base::FilePath workspaces_path_;
+ raw_ref pref_service_;
+ scoped_refptr io_task_runner_;
+
+ base::WeakPtrFactory weak_ptr_factory_{this};
+};
+
+#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_H_
diff --git a/browser/workspaces/workspace_service_factory.cc b/browser/workspaces/workspace_service_factory.cc
new file mode 100644
index 00000000000..0a2f3d8b9ad
--- /dev/null
+++ b/browser/workspaces/workspace_service_factory.cc
@@ -0,0 +1,53 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#include "brave/browser/workspaces/workspace_service_factory.h"
+
+#include
+
+#include "brave/browser/workspaces/features.h"
+#include "brave/browser/workspaces/pref_names.h"
+#include "brave/browser/workspaces/workspace_service.h"
+#include "chrome/browser/profiles/profile.h"
+#include "chrome/browser/profiles/profile_selections.h"
+#include "components/pref_registry/pref_registry_syncable.h"
+
+// static
+WorkspaceServiceFactory* WorkspaceServiceFactory::GetInstance() {
+ static base::NoDestructor instance;
+ return instance.get();
+}
+
+// static
+WorkspaceService* WorkspaceServiceFactory::GetForProfile(Profile* profile) {
+ return static_cast(
+ GetInstance()->GetServiceForBrowserContext(profile, /*create=*/true));
+}
+
+WorkspaceServiceFactory::WorkspaceServiceFactory()
+ : ProfileKeyedServiceFactory("WorkspaceService",
+ ProfileSelections::BuildForRegularProfile()) {}
+
+WorkspaceServiceFactory::~WorkspaceServiceFactory() = default;
+
+std::unique_ptr
+WorkspaceServiceFactory::BuildServiceInstanceForBrowserContext(
+ content::BrowserContext* context) const {
+ if (!base::FeatureList::IsEnabled(features::kWorkspaces)) {
+ return nullptr;
+ }
+
+ Profile* profile = Profile::FromBrowserContext(context);
+ return std::make_unique(*profile);
+}
+
+void WorkspaceServiceFactory::RegisterProfilePrefs(
+ user_prefs::PrefRegistrySyncable* registry) {
+ registry->RegisterDictionaryPref(kWorkspacesMetadataPref);
+}
+
+bool WorkspaceServiceFactory::ServiceIsCreatedWithBrowserContext() const {
+ return true;
+}
diff --git a/browser/workspaces/workspace_service_factory.h b/browser/workspaces/workspace_service_factory.h
new file mode 100644
index 00000000000..46f1796cf1a
--- /dev/null
+++ b/browser/workspaces/workspace_service_factory.h
@@ -0,0 +1,41 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_FACTORY_H_
+#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_FACTORY_H_
+
+#include "base/no_destructor.h"
+#include "chrome/browser/profiles/profile_keyed_service_factory.h"
+
+namespace user_prefs {
+class PrefRegistrySyncable;
+} // namespace user_prefs
+
+class WorkspaceService;
+class Profile;
+
+class WorkspaceServiceFactory : public ProfileKeyedServiceFactory {
+ public:
+ static WorkspaceServiceFactory* GetInstance();
+ static WorkspaceService* GetForProfile(Profile* profile);
+
+ WorkspaceServiceFactory(const WorkspaceServiceFactory&) = delete;
+ WorkspaceServiceFactory& operator=(const WorkspaceServiceFactory&) = delete;
+
+ private:
+ friend class base::NoDestructor;
+
+ WorkspaceServiceFactory();
+ ~WorkspaceServiceFactory() override;
+
+ // ProfileKeyedServiceFactory:
+ std::unique_ptr BuildServiceInstanceForBrowserContext(
+ content::BrowserContext* context) const override;
+ void RegisterProfilePrefs(
+ user_prefs::PrefRegistrySyncable* registry) override;
+ bool ServiceIsCreatedWithBrowserContext() const override;
+};
+
+#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_SERVICE_FACTORY_H_
diff --git a/browser/workspaces/workspace_service_unittest.cc b/browser/workspaces/workspace_service_unittest.cc
new file mode 100644
index 00000000000..3acc8c48697
--- /dev/null
+++ b/browser/workspaces/workspace_service_unittest.cc
@@ -0,0 +1,275 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#include "brave/browser/workspaces/workspace_service.h"
+
+#include
+#include
+
+#include "base/files/file_util.h"
+#include "base/functional/bind.h"
+#include "base/memory/raw_ptr.h"
+#include "base/task/bind_post_task.h"
+#include "base/task/sequenced_task_runner.h"
+#include "base/task/thread_pool.h"
+#include "base/test/bind.h"
+#include "base/test/run_until.h"
+#include "base/test/scoped_feature_list.h"
+#include "base/test/test_future.h"
+#include "base/time/time.h"
+#include "brave/browser/workspaces/features.h"
+#include "brave/browser/workspaces/workspace_metadata.h"
+#include "brave/browser/workspaces/workspace_service_factory.h"
+#include "brave/browser/workspaces/workspace_utils.h"
+#include "chrome/browser/profiles/profile.h"
+#include "chrome/test/base/testing_browser_process.h"
+#include "chrome/test/base/testing_profile_manager.h"
+#include "components/sessions/core/command_storage_backend.h"
+#include "components/sessions/core/session_id.h"
+#include "components/sessions/core/session_service_commands.h"
+#include "components/sessions/core/session_types.h"
+#include "content/public/test/browser_task_environment.h"
+#include "testing/gtest/include/gtest/gtest.h"
+
+namespace {
+WorkspaceMetadata MakeMeta(const std::string& name,
+ int windows,
+ int tabs,
+ base::Time t) {
+ WorkspaceMetadata meta{.name = name,
+ .modified_at = t,
+ .number_of_windows = windows,
+ .number_of_tabs = tabs};
+ return meta;
+}
+} // namespace
+
+class WorkspaceServiceTest : public ::testing::Test {
+ protected:
+ void SetUp() override {
+ ASSERT_TRUE(profile_manager_.SetUp());
+ // Instantiate the factory so it registers with
+ // BrowserContextDependencyManager before profile creation, ensuring
+ // kWorkspacesMetadataPref is registered.
+ WorkspaceServiceFactory::GetInstance();
+ profile_ = profile_manager_.CreateTestingProfile("test");
+ service_ = std::make_unique(*profile_);
+ }
+
+ content::BrowserTaskEnvironment task_environment_;
+ TestingProfileManager profile_manager_{TestingBrowserProcess::GetGlobal()};
+ raw_ptr profile_ = nullptr;
+ std::unique_ptr service_;
+};
+
+// ---- Metadata (pref) tests --------------------------------------------------
+
+// No workspaces listed w/ empty profile
+TEST_F(WorkspaceServiceTest, ListWorkspaces_InitiallyEmpty) {
+ EXPECT_TRUE(service_->ListWorkspaces().empty());
+}
+
+// Verify saving preference adds workspace to list
+TEST_F(WorkspaceServiceTest, SaveMetadata_AppearsInList) {
+ base::Time t = base::Time::FromSecondsSinceUnixEpoch(1700000000.0);
+ service_->SaveWorkspaceMetadata(MakeMeta("My Workspace", 2, 5, t));
+
+ auto workspaces = service_->ListWorkspaces();
+ ASSERT_EQ(workspaces.size(), 1u);
+ EXPECT_EQ(workspaces[0].name, "My Workspace");
+ EXPECT_EQ(workspaces[0].number_of_windows, 2);
+ EXPECT_EQ(workspaces[0].number_of_tabs, 5);
+ EXPECT_EQ(workspaces[0].modified_at,
+ base::Time::FromSecondsSinceUnixEpoch(1700000000.0));
+}
+
+// Verify listing of workspaces orders by date modified desc
+TEST_F(WorkspaceServiceTest, ListWorkspaces_SortedByModifiedAtDescending) {
+ base::Time t1 = base::Time::FromSecondsSinceUnixEpoch(1700000000.0);
+ base::Time t2 = base::Time::FromSecondsSinceUnixEpoch(1700000100.0);
+ base::Time t3 = base::Time::FromSecondsSinceUnixEpoch(1699999900.0);
+ service_->SaveWorkspaceMetadata(MakeMeta("Alpha", 1, 3, t1));
+ service_->SaveWorkspaceMetadata(MakeMeta("Beta", 1, 5, t2));
+ service_->SaveWorkspaceMetadata(MakeMeta("Gamma", 1, 2, t3));
+
+ auto workspaces = service_->ListWorkspaces();
+ ASSERT_EQ(workspaces.size(), 3u);
+ EXPECT_EQ(workspaces[0].name, "Beta");
+ EXPECT_EQ(workspaces[1].name, "Alpha");
+ EXPECT_EQ(workspaces[2].name, "Gamma");
+}
+
+// Removing preference removes it from the list
+TEST_F(WorkspaceServiceTest, RemoveMetadata_DisappearsFromList) {
+ base::Time t = base::Time::FromSecondsSinceUnixEpoch(1700000000.0);
+ service_->SaveWorkspaceMetadata(MakeMeta("Work", 1, 3, t));
+ ASSERT_EQ(service_->ListWorkspaces().size(), 1u);
+
+ service_->RemoveWorkspaceMetadata("Work");
+ EXPECT_TRUE(service_->ListWorkspaces().empty());
+}
+
+// Saving a workspace over another one replaces it
+TEST_F(WorkspaceServiceTest, SaveMetadata_SameNameOverwrites) {
+ base::Time t1 = base::Time::FromSecondsSinceUnixEpoch(1700000000.0);
+ base::Time t2 = base::Time::FromSecondsSinceUnixEpoch(1700000100.0);
+ service_->SaveWorkspaceMetadata(MakeMeta("Work", 1, 3, t1));
+ service_->SaveWorkspaceMetadata(MakeMeta("Work", 2, 7, t2));
+
+ auto workspaces = service_->ListWorkspaces();
+ ASSERT_EQ(workspaces.size(), 1u);
+ EXPECT_EQ(workspaces[0].name, "Work");
+ EXPECT_EQ(workspaces[0].number_of_windows, 2);
+ EXPECT_EQ(workspaces[0].number_of_tabs, 7);
+}
+
+// ---- Name / path computation tests -----------------------------------------
+
+// Key is a stable hex hash — different display names get different paths.
+TEST_F(WorkspaceServiceTest,
+ GetWorkspacePathForName_DifferentNamesDifferentPaths) {
+ base::FilePath path1 = service_->GetWorkspacePathForName("Work Space");
+ base::FilePath path2 = service_->GetWorkspacePathForName("Work-Space");
+ EXPECT_NE(path1, path2);
+}
+
+// Same name always resolves to the same path (deterministic key).
+TEST_F(WorkspaceServiceTest, GetWorkspacePathForName_SameNameSamePath) {
+ EXPECT_EQ(service_->GetWorkspacePathForName("My Workspace"),
+ service_->GetWorkspacePathForName("My Workspace"));
+}
+
+// ---- Disk I/O tests (async, use RunUntil not RunUntilIdle) ------------------
+
+// Verify that WriteWorkspaceToDisk saves the profile without calling onError.
+TEST_F(WorkspaceServiceTest, WriteAndReadRoundTrip) {
+ using CommandList = std::vector>;
+
+ SessionID window_id = SessionID::NewUnique();
+ CommandList commands;
+ commands.push_back(sessions::CreateSetWindowTypeCommand(
+ window_id, sessions::SessionWindow::TYPE_NORMAL));
+ commands.push_back(
+ sessions::CreateSetSelectedTabInWindowCommand(window_id, 0));
+ const size_t num_commands = commands.size();
+
+ base::FilePath workspace_path =
+ service_->GetWorkspacePathForName("roundtrip");
+ auto task_runner = base::ThreadPool::CreateSequencedTaskRunner(
+ {base::MayBlock(), base::TaskPriority::USER_VISIBLE,
+ base::TaskShutdownBehavior::BLOCK_SHUTDOWN});
+ auto backend = base::MakeRefCounted(
+ task_runner, workspace_path, kWorkspaceSessionType,
+ /*encryptor=*/std::nullopt);
+
+ bool error_called = false;
+ bool write_done = false;
+ auto on_error = base::BindPostTask(
+ base::SequencedTaskRunner::GetCurrentDefault(),
+ base::BindOnce([](bool* flag) { *flag = true; }, &error_called));
+
+ task_runner->PostTask(
+ FROM_HERE,
+ base::BindOnce(&WriteWorkspaceToDisk, std::move(commands), workspace_path,
+ std::move(backend), std::move(on_error)));
+ // A sequenced task after the write runs only after it completes.
+ task_runner->PostTask(
+ FROM_HERE,
+ base::BindPostTask(
+ base::SequencedTaskRunner::GetCurrentDefault(),
+ base::BindOnce([](bool* flag) { *flag = true; }, &write_done)));
+
+ ASSERT_TRUE(base::test::RunUntil([&] { return write_done; }));
+ ASSERT_FALSE(error_called);
+
+ // Read back using a fresh backend (mirrors RestoreWorkspace's pattern).
+ auto read_backend = base::MakeRefCounted(
+ task_runner, workspace_path, kWorkspaceSessionType,
+ /*encryptor=*/std::nullopt);
+ base::test::TestFuture read_future;
+ task_runner->PostTaskAndReplyWithResult(
+ FROM_HERE,
+ base::BindOnce(&ReadWorkspaceFromDisk, workspace_path, read_backend),
+ read_future.GetCallback());
+
+ ASSERT_TRUE(read_future.Wait());
+ const auto& read_cmds = read_future.Get();
+ ASSERT_EQ(read_cmds.size(), num_commands);
+
+ // Verify content — not just count — survived the round-trip.
+ // Re-creating the reference commands from the same inputs and comparing
+ // serialized bytes confirms the window ID and TYPE_NORMAL encoding are
+ // preserved end-to-end (both are embedded in the serialized payload).
+ EXPECT_EQ(read_cmds[0]->Serialize(),
+ sessions::CreateSetWindowTypeCommand(
+ window_id, sessions::SessionWindow::TYPE_NORMAL)
+ ->Serialize());
+ EXPECT_EQ(
+ read_cmds[1]->Serialize(),
+ sessions::CreateSetSelectedTabInWindowCommand(window_id, 0)->Serialize());
+}
+
+TEST_F(WorkspaceServiceTest,
+ WriteToDisk_DirectoryCreationFailure_CallsOnError) {
+ // Place a regular FILE where the workspace directory would be so that
+ // base::CreateDirectory fails on the background thread and on_error is
+ // posted back to the UI thread.
+ ASSERT_TRUE(base::CreateDirectory(service_->GetWorkspacesPath()));
+ base::FilePath workspace_path = service_->GetWorkspacePathForName("blocked");
+ ASSERT_TRUE(base::WriteFile(workspace_path, "not-a-directory"));
+
+ auto task_runner = base::ThreadPool::CreateSequencedTaskRunner(
+ {base::MayBlock(), base::TaskPriority::USER_VISIBLE,
+ base::TaskShutdownBehavior::BLOCK_SHUTDOWN});
+ auto backend = base::MakeRefCounted(
+ task_runner, workspace_path, kWorkspaceSessionType,
+ /*encryptor=*/std::nullopt);
+
+ bool error_called = false;
+ auto on_error = base::BindPostTask(
+ base::SequencedTaskRunner::GetCurrentDefault(),
+ base::BindOnce([](bool* flag) { *flag = true; }, &error_called));
+
+ task_runner->PostTask(
+ FROM_HERE,
+ base::BindOnce(&WriteWorkspaceToDisk,
+ std::vector>(),
+ workspace_path, std::move(backend), std::move(on_error)));
+
+ EXPECT_TRUE(base::test::RunUntil([&] { return error_called; }));
+}
+
+// ---- Factory tests ----------------------------------------------------------
+
+class WorkspaceServiceFactoryTest : public ::testing::Test {
+ protected:
+ void SetUp() override { ASSERT_TRUE(profile_manager_.SetUp()); }
+
+ content::BrowserTaskEnvironment task_environment_;
+ base::test::ScopedFeatureList feature_list_;
+ TestingProfileManager profile_manager_{TestingBrowserProcess::GetGlobal()};
+};
+
+TEST_F(WorkspaceServiceFactoryTest, FeatureDisabled_GetForProfileReturnsNull) {
+ feature_list_.InitAndDisableFeature(features::kWorkspaces);
+ auto* profile = profile_manager_.CreateTestingProfile("test");
+ EXPECT_EQ(WorkspaceServiceFactory::GetForProfile(profile), nullptr);
+}
+
+TEST_F(WorkspaceServiceFactoryTest,
+ FeatureEnabled_GetForProfileReturnsNonNull) {
+ feature_list_.InitAndEnableFeature(features::kWorkspaces);
+ auto* profile = profile_manager_.CreateTestingProfile("test");
+ // ServiceIsNULLWhileTesting() suppresses service creation for test profiles.
+ // Provide a testing factory so the feature-enabled path is exercised.
+ WorkspaceServiceFactory::GetInstance()->SetTestingFactory(
+ profile,
+ base::BindLambdaForTesting(
+ [](content::BrowserContext* ctx) -> std::unique_ptr {
+ auto* p = Profile::FromBrowserContext(ctx);
+ return std::make_unique(*p);
+ }));
+ EXPECT_NE(WorkspaceServiceFactory::GetForProfile(profile), nullptr);
+}
diff --git a/browser/workspaces/workspace_session_utils.cc b/browser/workspaces/workspace_session_utils.cc
new file mode 100644
index 00000000000..4d94d690617
--- /dev/null
+++ b/browser/workspaces/workspace_session_utils.cc
@@ -0,0 +1,231 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#include "brave/browser/workspaces/workspace_session_utils.h"
+
+#include
+#include
+#include
+#include
+
+#include "brave/browser/workspaces/workspace_metadata.h"
+#include "chrome/browser/profiles/profile.h"
+#include "chrome/browser/ui/browser.h"
+#include "chrome/browser/ui/browser_tabrestore.h"
+#include "chrome/browser/ui/browser_window.h"
+#include "chrome/browser/ui/browser_window/public/browser_window_interface.h"
+#include "chrome/browser/ui/browser_window/public/global_browser_collection.h"
+#include "chrome/browser/ui/tabs/tab_group_model.h"
+#include "chrome/browser/ui/tabs/tab_strip_model.h"
+#include "components/sessions/content/content_serialized_navigation_builder.h"
+#include "components/sessions/core/session_service_commands.h"
+#include "components/sessions/core/session_types.h"
+#include "components/tab_groups/tab_group_visual_data.h"
+#include "components/tabs/public/tab_group.h"
+#include "content/public/browser/navigation_controller.h"
+#include "content/public/browser/navigation_entry.h"
+#include "content/public/browser/web_contents.h"
+
+namespace {
+
+// Appends session commands for a single browser window to |commands|.
+// Serializes window type, bounds, tab groups, tabs (with full navigation
+// history), pinned state, and the active tab index.
+void AppendBrowserSessionCommands(
+ const SessionID& window_id,
+ TabStripModel* tsm,
+ gfx::Rect restored_bounds,
+ ui::mojom::WindowShowState restored_state,
+ std::vector>& commands) {
+ commands.push_back(sessions::CreateSetWindowTypeCommand(
+ window_id, sessions::SessionWindow::TYPE_NORMAL));
+ commands.push_back(sessions::CreateSetWindowBoundsCommand(
+ window_id, restored_bounds, restored_state));
+
+ // Emit group metadata for every tab group in this window.
+ if (tsm->group_model()) {
+ for (const tab_groups::TabGroupId& group_id :
+ tsm->group_model()->ListTabGroups()) {
+ auto* group = tsm->group_model()->GetTabGroup(group_id);
+ if (!group || !group->visual_data()) {
+ continue;
+ }
+ commands.push_back(sessions::CreateTabGroupMetadataUpdateCommand(
+ group_id, group->visual_data()));
+ }
+ }
+
+ for (int i = 0; i < tsm->count(); ++i) {
+ content::WebContents* contents = tsm->GetWebContentsAt(i);
+ CHECK(contents);
+
+ SessionID tab_id = SessionID::NewUnique();
+
+ commands.push_back(sessions::CreateSetTabWindowCommand(window_id, tab_id));
+ commands.push_back(sessions::CreateSetTabIndexInWindowCommand(tab_id, i));
+
+ if (tsm->IsTabPinned(i)) {
+ commands.push_back(sessions::CreatePinnedStateCommand(tab_id, true));
+ }
+
+ std::optional group_id = tsm->GetTabGroupForTab(i);
+ if (group_id.has_value()) {
+ commands.push_back(sessions::CreateTabGroupCommand(tab_id, group_id));
+ }
+
+ // Serialize the full navigation history for this tab.
+ auto& controller = contents->GetController();
+ int nav_count = controller.GetEntryCount();
+ int current_entry = controller.GetCurrentEntryIndex();
+ for (int j = 0; j < nav_count; ++j) {
+ content::NavigationEntry* entry = controller.GetEntryAtIndex(j);
+ if (!entry) {
+ continue;
+ }
+ auto serialized =
+ sessions::ContentSerializedNavigationBuilder::FromNavigationEntry(
+ j, entry);
+ commands.push_back(
+ sessions::CreateUpdateTabNavigationCommand(tab_id, serialized));
+ }
+
+ commands.push_back(sessions::CreateSetSelectedNavigationIndexCommand(
+ tab_id, current_entry));
+ }
+
+ commands.push_back(sessions::CreateSetSelectedTabInWindowCommand(
+ window_id, tsm->active_index()));
+}
+
+} // namespace
+
+std::vector>
+GenerateBrowserSessionCommandsForWorkspace(Profile* profile,
+ WorkspaceMetadata& workspace) {
+ std::vector> commands;
+ SessionID active_window_id = SessionID::InvalidValue();
+
+ GlobalBrowserCollection::GetInstance()->ForEach(
+ [&](BrowserWindowInterface* bwi) {
+ if (bwi->GetProfile() != profile ||
+ bwi->GetType() != BrowserWindowInterface::Type::TYPE_NORMAL) {
+ return true;
+ }
+
+ int tabs = bwi->GetTabStripModel()->count();
+ if (tabs == 0) {
+ return true;
+ }
+
+ SessionID window_id = SessionID::NewUnique();
+ auto* window = bwi->GetWindow();
+ AppendBrowserSessionCommands(window_id, bwi->GetTabStripModel(),
+ window->GetRestoredBounds(),
+ window->GetRestoredState(), commands);
+
+ // Prefer the focused window; fall back to the first non-empty window.
+ if (window->IsActive() ||
+ active_window_id == SessionID::InvalidValue()) {
+ active_window_id = window_id;
+ }
+ workspace.number_of_windows++;
+ workspace.number_of_tabs += tabs;
+ return true;
+ });
+
+ if (workspace.number_of_tabs == 0) {
+ return {};
+ }
+
+ if (active_window_id != SessionID::InvalidValue()) {
+ commands.push_back(
+ sessions::CreateSetActiveWindowCommand(active_window_id));
+ }
+
+ return commands;
+}
+
+void RestoreBrowserSessionCommandsForWorkspace(
+ Profile* profile,
+ std::vector> commands) {
+ // RestoreSessionFromCommands constructs SessionTab/SessionWindow objects
+ // whose constructors call SessionID::NewUnique(), which is sequence-checked
+ // to the UI thread. It must therefore be called here (UI thread), not in
+ // the background I/O task.
+ std::vector> windows;
+ SessionID active_window_id = SessionID::InvalidValue();
+ std::string platform_session_id;
+ std::set discarded_window_ids;
+ sessions::RestoreSessionFromCommands(commands, &windows, &active_window_id,
+ &platform_session_id,
+ &discarded_window_ids);
+ if (windows.empty()) {
+ return;
+ }
+
+ // We restore tabs ourselves rather than using RestoreForeignSessionWindows
+ // because that API creates an empty new_group_ids map and never calls
+ // RestoreTabGroupMetadata, so tab group names/colors are silently dropped.
+ // By passing the original TabGroupId directly to AddRestoredTab, we avoid
+ // any ID remapping and can apply visual data with the same IDs afterwards.
+ for (const auto& window : windows) {
+ if (window->tabs.empty()) {
+ continue;
+ }
+
+ if (Browser::GetCreationStatusForProfile(profile) !=
+ Browser::CreationStatus::kOk) {
+ continue;
+ }
+
+ Browser::CreateParams params(Browser::TYPE_NORMAL, profile, false);
+ params.initial_bounds = window->bounds;
+ params.initial_show_state = window->show_state;
+ params.initial_workspace = window->workspace;
+ params.initial_visible_on_all_workspaces_state =
+ window->visible_on_all_workspaces;
+ params.should_trigger_session_restore = false;
+ Browser* browser = Browser::Create(params);
+ if (!browser) {
+ continue;
+ }
+
+ auto* tsm = browser->tab_strip_model();
+ for (size_t i = 0; i < window->tabs.size(); ++i) {
+ const auto& tab = window->tabs[i];
+ if (tab->navigations.empty()) {
+ continue;
+ }
+ chrome::AddRestoredTab(
+ browser, tab->navigations,
+ /*tab_index=*/i, tab->normalized_navigation_index(),
+ tab->extension_app_id,
+ /*group=*/tab->group,
+ /*select=*/false, tab->pinned,
+ /*last_active_time_ticks=*/base::TimeTicks(), tab->last_active_time,
+ /*storage_namespace=*/nullptr, tab->user_agent_override,
+ tab->extra_data,
+ /*from_session_restore=*/true,
+ /*is_active_browser=*/std::nullopt);
+ }
+
+ // Apply group names, colors, and collapsed state. Since we passed the
+ // original TabGroupIds to AddRestoredTab, no ID remapping is needed.
+ for (const auto& session_group : window->tab_groups) {
+ if (!tsm->group_model() ||
+ !tsm->group_model()->ContainsTabGroup(session_group->id)) {
+ continue;
+ }
+ tsm->ChangeTabGroupVisuals(session_group->id, session_group->visual_data);
+ }
+
+ int active = std::clamp(window->selected_tab_index, 0,
+ std::max(0, tsm->count() - 1));
+ if (active < tsm->count()) {
+ tsm->ActivateTabAt(active);
+ }
+ browser->window()->Show();
+ }
+}
diff --git a/browser/workspaces/workspace_session_utils.h b/browser/workspaces/workspace_session_utils.h
new file mode 100644
index 00000000000..b5aec5dd61c
--- /dev/null
+++ b/browser/workspaces/workspace_session_utils.h
@@ -0,0 +1,35 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_SESSION_UTILS_H_
+#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_SESSION_UTILS_H_
+
+#include
+#include
+
+#include "components/sessions/core/session_command.h"
+#include "components/sessions/core/session_id.h"
+#include "ui/base/mojom/window_show_state.mojom.h"
+#include "ui/gfx/geometry/rect.h"
+
+class Profile;
+class TabStripModel;
+struct WorkspaceMetadata;
+
+// Iterates all normal browser windows for |profile|, serializes their state
+// into session commands, and returns the result. |window_count| and
+// |tab_count| are incremented to reflect what was captured. Returns an empty
+// vector if there are no tabs to save.
+std::vector>
+GenerateBrowserSessionCommandsForWorkspace(Profile* profile,
+ WorkspaceMetadata& workspace);
+
+// Deserializes |commands| and opens the encoded windows/tabs in new browser
+// windows belonging to |profile|. Must be called on the UI thread.
+void RestoreBrowserSessionCommandsForWorkspace(
+ Profile* profile,
+ std::vector> commands);
+
+#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_SESSION_UTILS_H_
diff --git a/browser/workspaces/workspace_utils.cc b/browser/workspaces/workspace_utils.cc
new file mode 100644
index 00000000000..e93c6a125ae
--- /dev/null
+++ b/browser/workspaces/workspace_utils.cc
@@ -0,0 +1,92 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#include "brave/browser/workspaces/workspace_utils.h"
+
+#include
+#include
+#include
+#include
+
+#include "base/files/file_util.h"
+#include "base/json/values_util.h"
+#include "base/logging.h"
+#include "base/time/time.h"
+
+namespace {
+
+constexpr const char kWorkspaceName[] = "name";
+constexpr const char kWorkspaceWindowCount[] = "number-of-windows";
+constexpr const char kWorkspaceTabCount[] = "number-of-tabs";
+constexpr const char kWorkspaceModifiedAt[] = "modified-at";
+
+} // namespace
+
+std::vector ListWorkspacesFromDict(
+ const base::DictValue& dict) {
+ std::vector result;
+ for (const auto [key, value] : dict) {
+ const base::DictValue* entry = value.GetIfDict();
+ if (!entry) {
+ continue;
+ }
+ const std::string* name = entry->FindString(kWorkspaceName);
+ if (!name || name->empty()) {
+ continue;
+ }
+ WorkspaceMetadata info = {
+ .name = *name,
+ .modified_at = base::ValueToTime(entry->Find(kWorkspaceModifiedAt))
+ .value_or(base::Time::Now()),
+ .number_of_windows = entry->FindInt(kWorkspaceWindowCount).value_or(1),
+ .number_of_tabs = entry->FindInt(kWorkspaceTabCount).value_or(0)};
+ result.push_back(std::move(info));
+ }
+
+ std::sort(result.begin(), result.end(),
+ [](const WorkspaceMetadata& a, const WorkspaceMetadata& b) {
+ return a.modified_at > b.modified_at;
+ });
+ return result;
+}
+
+base::DictValue WorkspaceMetadataToDictEntry(const WorkspaceMetadata& meta) {
+ return base::DictValue()
+ .Set(kWorkspaceName, meta.name)
+ .Set(kWorkspaceWindowCount, meta.number_of_windows)
+ .Set(kWorkspaceTabCount, meta.number_of_tabs)
+ .Set(kWorkspaceModifiedAt, base::TimeToValue(meta.modified_at));
+}
+
+void WriteWorkspaceToDisk(
+ std::vector> commands,
+ const base::FilePath& workspace_path,
+ scoped_refptr backend,
+ base::OnceClosure on_error) {
+ if (!base::CreateDirectory(workspace_path)) {
+ DVLOG(1) << "Failed to create workspace directory: " << workspace_path;
+ std::move(on_error).Run();
+ return;
+ }
+ // AppendCommands posts |on_error| to the backend's callback_task_runner_
+ // (the UI thread, set when the backend was constructed) on write failure.
+ backend->AppendCommands(std::move(commands), /*truncate=*/true,
+ std::move(on_error));
+}
+
+std::vector> ReadWorkspaceFromDisk(
+ const base::FilePath& workspace_path,
+ scoped_refptr backend) {
+ // Only do file I/O here. Callers must call RestoreSessionFromCommands() on
+ // the UI thread because SessionTab/SessionWindow constructors call
+ // SessionID::NewUnique() which is sequence-checked to the UI thread.
+ sessions::CommandStorageBackend::ReadCommandsResult result =
+ backend->ReadLastSessionCommands();
+ if (result.error_reading || result.commands.empty()) {
+ DVLOG(1) << "Could not read workspace session from: " << workspace_path;
+ return {};
+ }
+ return std::move(result.commands);
+}
diff --git a/browser/workspaces/workspace_utils.h b/browser/workspaces/workspace_utils.h
new file mode 100644
index 00000000000..7f05917480c
--- /dev/null
+++ b/browser/workspaces/workspace_utils.h
@@ -0,0 +1,60 @@
+/* Copyright (c) 2026 The Brave Authors. All rights reserved.
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at https://mozilla.org/MPL/2.0/. */
+
+#ifndef BRAVE_BROWSER_WORKSPACES_WORKSPACE_UTILS_H_
+#define BRAVE_BROWSER_WORKSPACES_WORKSPACE_UTILS_H_
+
+#include
+#include
+
+#include "base/files/file_path.h"
+#include "base/functional/callback.h"
+#include "base/memory/scoped_refptr.h"
+#include "base/values.h"
+#include "brave/browser/workspaces/workspace_metadata.h"
+#include "components/sessions/core/command_storage_backend.h"
+#include "components/sessions/core/command_storage_manager.h"
+#include "components/sessions/core/session_command.h"
+
+// Session type used for all workspace files. Exposed so callers can
+// construct a CommandStorageBackend on the UI thread before posting I/O
+// work to a background sequenced runner.
+inline constexpr sessions::CommandStorageManager::SessionType
+ kWorkspaceSessionType =
+ sessions::CommandStorageManager::SessionType::kSessionRestore;
+
+// Writes |commands| to a Chromium session-command binary file via |backend|.
+// |on_error| is called directly if the workspace directory cannot be created,
+// or is posted to the UI thread (via AppendCommands' callback_task_runner_) on
+// write failure. Wrap with BindPostTask in the caller so it always lands on
+// the UI thread regardless of which path fires.
+//
+// Must run on a background sequenced task runner.
+void WriteWorkspaceToDisk(
+ std::vector> commands,
+ const base::FilePath& workspace_path,
+ scoped_refptr backend,
+ base::OnceClosure on_error);
+
+// Reads the session-command binary via |backend| and returns the raw commands.
+// Only does file I/O — callers must deserialize the commands into
+// SessionWindow objects on the UI thread (SessionID::NewUnique() is
+// sequence-checked to the UI thread). Returns an empty vector on error.
+//
+// Must run on a background sequenced task runner.
+std::vector> ReadWorkspaceFromDisk(
+ const base::FilePath& workspace_path,
+ scoped_refptr backend);
+
+// Parses a workspace metadata dict (the value stored under
+// kWorkspacesMetadataPref) and returns a list sorted by modified_at descending.
+std::vector ListWorkspacesFromDict(
+ const base::DictValue& dict);
+
+// Serializes |meta| into a single dict entry suitable for storing under its key
+// in kWorkspacesMetadataPref.
+base::DictValue WorkspaceMetadataToDictEntry(const WorkspaceMetadata& meta);
+
+#endif // BRAVE_BROWSER_WORKSPACES_WORKSPACE_UTILS_H_
diff --git a/test/BUILD.gn b/test/BUILD.gn
index 87f3714c29c..36f5ebb9587 100644
--- a/test/BUILD.gn
+++ b/test/BUILD.gn
@@ -509,6 +509,7 @@ test("brave_unit_tests") {
"//brave/browser/ui/webui/settings:unit_tests",
"//brave/browser/ui/webui/side_panel/customize_chrome:unit_tests",
"//brave/browser/ui/whats_new:unit_test",
+ "//brave/browser/workspaces:unit_tests",
"//brave/components/ai_chat/core/common/buildflags",
"//brave/components/brave_shields/core/common:mojom",
"//brave/components/brave_vpn/common/buildflags",