Compare commits

..
12 changed files with 535 additions and 4 deletions
+2 -1
View File
@@ -133,7 +133,8 @@ or a result you must retain.
Use the workspace picker before starting project-specific work. This gives the Use the workspace picker before starting project-specific work. This gives the
agent the right project context for file paths, shell commands, and session agent the right project context for file paths, shell commands, and session
metadata. metadata. A locally hosted WebUI opens the operating system's folder chooser
when one is available; remote deployments keep the manual absolute path entry.
Selecting a project does not replace the configured agent workspace. The two Selecting a project does not replace the configured agent workspace. The two
paths have different responsibilities: paths have different responsibilities:
@@ -3266,6 +3266,85 @@ async def _webui_mutate(
) )
@pytest.mark.asyncio
async def test_workspace_folder_picker_is_local_authenticated_mutation(
bus: MagicMock,
tmp_path: Path,
monkeypatch,
) -> None:
selected = tmp_path / "project"
selected.mkdir()
pick_folder = AsyncMock(return_value=str(selected))
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus)
response = await _webui_mutate(channel, "workspace.pick_folder")
assert response.status_code == 200
assert response.json() == {"path": str(selected)}
pick_folder.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_workspace_folder_picker_rejects_direct_http(
bus: MagicMock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pick_folder = AsyncMock(return_value="/tmp")
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus)
response = await channel.gateway.http.dispatch(
_LOCAL,
_FakeReq(
{"Host": "127.0.0.1:8765"},
path="/api/workspaces/pick-folder",
),
)
assert response is not None
assert response.status_code == 405
assert b"authenticated WebSocket" in response.body
pick_folder.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("connection", "host"),
[(_REMOTE, "127.0.0.1"), (_LOCAL, "0.0.0.0")],
)
async def test_workspace_folder_picker_rejects_nonlocal_surfaces(
bus: MagicMock,
monkeypatch,
connection: _FakeConn,
host: str,
) -> None:
pick_folder = AsyncMock(return_value="/tmp")
monkeypatch.setattr(
"nanobot.webui.ws_http.native_folder_picker_available",
lambda: True,
)
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
channel = _ch(bus, host=host, token="test-token" if host == "0.0.0.0" else "")
response = await _webui_mutate(
channel,
"workspace.pick_folder",
connection=connection,
)
assert response.status_code == 403
pick_folder.assert_not_awaited()
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None: def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
from nanobot.webui.http_utils import is_local_browser_request from nanobot.webui.http_utils import is_local_browser_request
+211
View File
@@ -0,0 +1,211 @@
"""Native directory picker used by a locally hosted WebUI."""
from __future__ import annotations
import asyncio
import os
import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
_PICKER_TIMEOUT_SECONDS = 300
_COMMON_ENV_KEYS = (
"HOME",
"LANG",
"LANGUAGE",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"LOGNAME",
"PATH",
"SHELL",
"TMPDIR",
"USER",
)
_LINUX_GUI_ENV_KEYS = (
"DBUS_SESSION_BUS_ADDRESS",
"DESKTOP_SESSION",
"DISPLAY",
"WAYLAND_DISPLAY",
"XAUTHORITY",
"XDG_CURRENT_DESKTOP",
"XDG_RUNTIME_DIR",
)
_MACOS_GUI_ENV_KEYS = ("SECURITYSESSIONID", "__CF_USER_TEXT_ENCODING")
_WINDOWS_GUI_ENV_KEYS = (
"APPDATA",
"COMSPEC",
"HOMEDRIVE",
"HOMEPATH",
"LOCALAPPDATA",
"PATHEXT",
"ProgramData",
"ProgramFiles",
"ProgramFiles(x86)",
"ProgramW6432",
"SESSIONNAME",
"SYSTEMROOT",
"TEMP",
"TMP",
"USERDOMAIN",
"USERNAME",
"USERPROFILE",
)
class NativeFolderPickerError(RuntimeError):
"""Raised when an available native folder picker cannot complete."""
@dataclass(frozen=True)
class _PickerCommand:
argv: tuple[str, ...]
cancel_codes: frozenset[int]
cancel_markers: tuple[str, ...] = ()
def _picker_command() -> _PickerCommand | None:
if sys.platform == "darwin":
executable = shutil.which("osascript")
if executable is None:
return None
return _PickerCommand(
argv=(
executable,
"-e",
'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
"-e",
"POSIX path of selectedFolder",
),
cancel_codes=frozenset({1}),
cancel_markers=("user canceled", "(-128)"),
)
if sys.platform == "win32":
executable = shutil.which("powershell.exe") or shutil.which("powershell")
if executable is None:
return None
script = (
"Add-Type -AssemblyName System.Windows.Forms;"
"$dialog=New-Object System.Windows.Forms.FolderBrowserDialog;"
"$dialog.Description='Select Workspace Directory';"
"$dialog.ShowNewFolderButton=$true;"
"if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){"
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();"
"[Console]::Out.Write($dialog.SelectedPath)}"
)
return _PickerCommand(
argv=(
executable,
"-NoProfile",
"-NonInteractive",
"-STA",
"-Command",
script,
),
cancel_codes=frozenset(),
)
if sys.platform.startswith("linux"):
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return None
zenity = shutil.which("zenity")
if zenity is not None:
return _PickerCommand(
argv=(
zenity,
"--file-selection",
"--directory",
"--title=Select Workspace Directory",
),
cancel_codes=frozenset({1}),
)
kdialog = shutil.which("kdialog")
if kdialog is not None:
return _PickerCommand(
argv=(kdialog, "--getexistingdirectory", str(Path.home())),
cancel_codes=frozenset({1}),
)
return None
def native_folder_picker_available() -> bool:
"""Return whether this host can display a native directory picker."""
return _picker_command() is not None
def _picker_environment() -> dict[str, str]:
"""Pass only host UI/runtime variables, never provider or gateway secrets."""
keys: list[str] = list(_COMMON_ENV_KEYS)
if sys.platform == "darwin":
keys.extend(_MACOS_GUI_ENV_KEYS)
elif sys.platform == "win32":
keys.extend(_WINDOWS_GUI_ENV_KEYS)
elif sys.platform.startswith("linux"):
keys.extend(_LINUX_GUI_ENV_KEYS)
return {
key: value
for key in keys
if (value := os.environ.get(key)) is not None
}
async def _stop_process(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=2)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def pick_native_folder() -> str | None:
"""Open the platform directory picker and return an existing absolute path."""
command = _picker_command()
if command is None:
raise NativeFolderPickerError("native folder picker is unavailable on this host")
try:
process = await asyncio.create_subprocess_exec(
*command.argv,
env=_picker_environment(),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except OSError as exc:
raise NativeFolderPickerError("native folder picker failed to start") from exc
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=_PICKER_TIMEOUT_SECONDS,
)
except asyncio.CancelledError:
await _stop_process(process)
raise
except TimeoutError as exc:
await _stop_process(process)
raise NativeFolderPickerError("native folder picker timed out") from exc
error_text = stderr.decode("utf-8", errors="replace").strip()
normalized_error = error_text.lower()
if process.returncode != 0:
if process.returncode in command.cancel_codes and (
not command.cancel_markers
or any(marker in normalized_error for marker in command.cancel_markers)
):
return None
raise NativeFolderPickerError("native folder picker failed")
selected = stdout.decode("utf-8", errors="replace").strip()
if not selected:
return None
path = Path(selected).expanduser()
if not path.is_absolute() or not path.is_dir():
raise NativeFolderPickerError("native folder picker returned an invalid directory")
return str(path)
+9 -1
View File
@@ -149,6 +149,7 @@ def workspaces_payload(
default_workspace: Path, default_workspace: Path,
default_restrict_to_workspace: bool, default_restrict_to_workspace: bool,
controls_available: bool, controls_available: bool,
folder_picker_available: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
default_access_mode = read_webui_default_access_mode() default_access_mode = read_webui_default_access_mode()
default_scope = ( default_scope = (
@@ -167,6 +168,7 @@ def workspaces_payload(
"controls": { "controls": {
"can_change_project": controls_available, "can_change_project": controls_available,
"can_use_full_access": controls_available, "can_use_full_access": controls_available,
"can_pick_folder": folder_picker_available,
}, },
} }
@@ -241,11 +243,17 @@ class WebUIWorkspaceController:
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY)) cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
) )
def payload(self, *, controls_available: bool) -> dict[str, Any]: def payload(
self,
*,
controls_available: bool,
folder_picker_available: bool = False,
) -> dict[str, Any]:
return workspaces_payload( return workspaces_payload(
default_workspace=self._default_workspace, default_workspace=self._default_workspace,
default_restrict_to_workspace=self._default_restrict_to_workspace, default_restrict_to_workspace=self._default_restrict_to_workspace,
controls_available=controls_available, controls_available=controls_available,
folder_picker_available=folder_picker_available,
) )
def scope_from_envelope( def scope_from_envelope(
+45 -1
View File
@@ -62,6 +62,7 @@ from nanobot.webui.http_utils import (
from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import (
is_localhost as _is_localhost, is_localhost as _is_localhost,
) )
from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host
from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import (
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request, is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
) )
@@ -85,6 +86,11 @@ from nanobot.webui.http_utils import (
) )
from nanobot.webui.ingress_policy import WebUIIngressPolicy from nanobot.webui.ingress_policy import WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.native_folder_picker import (
NativeFolderPickerError,
native_folder_picker_available,
pick_native_folder,
)
from nanobot.webui.session_automations import ( from nanobot.webui.session_automations import (
all_automations_payload, all_automations_payload,
serialize_automation_jobs, serialize_automation_jobs,
@@ -133,6 +139,7 @@ _WEBUI_MUTATION_PATHS = {
"skill.update": "/api/webui/skills/update", "skill.update": "/api/webui/skills/update",
"skill.delete": "/api/webui/skills/delete", "skill.delete": "/api/webui/skills/delete",
"sidebar.update": "/api/webui/sidebar-state/update", "sidebar.update": "/api/webui/sidebar-state/update",
"workspace.pick_folder": "/api/workspaces/pick-folder",
"settings.agent.update": "/api/settings/update", "settings.agent.update": "/api/settings/update",
"settings.model_configuration.create": "/api/settings/model-configurations/create", "settings.model_configuration.create": "/api/settings/model-configurations/create",
"settings.model_configuration.update": "/api/settings/model-configurations/update", "settings.model_configuration.update": "/api/settings/model-configurations/update",
@@ -329,6 +336,7 @@ class GatewayHTTPHandler:
) )
self.skill_state_action = skill_state_action self.skill_state_action = skill_state_action
self._skill_install_lock = asyncio.Lock() self._skill_install_lock = asyncio.Lock()
self._folder_picker_lock = asyncio.Lock()
self.cron_service = cron_service self.cron_service = cron_service
self.local_trigger_store = local_trigger_store self.local_trigger_store = local_trigger_store
self.cron_pending_job_ids = cron_pending_job_ids self.cron_pending_job_ids = cron_pending_job_ids
@@ -360,6 +368,17 @@ class GatewayHTTPHandler:
def workspace_controls_available(self, connection: Any) -> bool: def workspace_controls_available(self, connection: Any) -> bool:
return self._runtime_surface == "native" or _is_localhost(connection) return self._runtime_surface == "native" or _is_localhost(connection)
def workspace_folder_picker_available(
self,
connection: Any,
request: WsRequest,
) -> bool:
return (
_is_loopback_host(self.config.host)
and _is_local_browser_request(connection, request.headers)
and native_folder_picker_available()
)
# -- Token management --------------------------------------------------- # -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool: def check_api_token(self, request: WsRequest) -> bool:
@@ -435,6 +454,7 @@ class GatewayHTTPHandler:
"/api/webui/skills/update", "/api/webui/skills/update",
"/api/webui/skills/delete", "/api/webui/skills/delete",
"/api/webui/sidebar-state/update", "/api/webui/sidebar-state/update",
"/api/workspaces/pick-folder",
} }
@staticmethod @staticmethod
@@ -1054,6 +1074,8 @@ class GatewayHTTPHandler:
return await self._handle_sessions_list(request) return await self._handle_sessions_list(request)
if got == "/api/commands": if got == "/api/commands":
return self._handle_commands(request) return self._handle_commands(request)
if got == "/api/workspaces/pick-folder":
return await self._handle_workspace_folder_picker(connection, request)
if got == "/api/workspaces": if got == "/api/workspaces":
return self._handle_workspaces(connection, request) return self._handle_workspaces(connection, request)
if got == "/api/webui/skills/search": if got == "/api/webui/skills/search":
@@ -1089,10 +1111,32 @@ class GatewayHTTPHandler:
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
return _http_json_response( return _http_json_response(
self.workspaces.payload( self.workspaces.payload(
controls_available=self.workspace_controls_available(connection) controls_available=self.workspace_controls_available(connection),
folder_picker_available=self.workspace_folder_picker_available(
connection,
request,
),
) )
) )
async def _handle_workspace_folder_picker(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if not self.workspace_folder_picker_available(connection, request):
return _http_error(403, "native folder picker is unavailable for this connection")
if self._folder_picker_lock.locked():
return _http_error(409, "native folder picker is already open")
try:
async with self._folder_picker_lock:
path = await pick_native_folder()
except NativeFolderPickerError as exc:
return _http_error(503, str(exc))
return _http_json_response({"path": path})
def _handle_webui_skills(self, request: WsRequest) -> Response: def _handle_webui_skills(self, request: WsRequest) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from nanobot.webui import native_folder_picker as picker
def _picker_command(tmp_path: Path, body: str) -> picker._PickerCommand:
script = tmp_path / "picker.py"
script.write_text(f"{body}\n", encoding="utf-8")
return picker._PickerCommand((sys.executable, str(script)), frozenset({1}))
@pytest.mark.asyncio
async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypatch) -> None:
selected = tmp_path / "project"
selected.mkdir()
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
assert await picker.pick_native_folder() == str(selected)
@pytest.mark.asyncio
async def test_pick_native_folder_uses_secret_free_environment(tmp_path, monkeypatch) -> None:
selected = tmp_path / "project"
selected.mkdir()
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
monkeypatch.setattr(picker, "_picker_command", lambda: command)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("OPENAI_API_KEY", "must-not-reach-picker")
original_spawn = picker.asyncio.create_subprocess_exec
captured_env: dict[str, str] = {}
async def capture_spawn(*args, **kwargs):
captured_env.update(kwargs["env"])
return await original_spawn(*args, **kwargs)
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", capture_spawn)
assert await picker.pick_native_folder() == str(selected)
assert captured_env["HOME"] == str(tmp_path)
assert "OPENAI_API_KEY" not in captured_env
def test_picker_environment_preserves_linux_display_context(monkeypatch) -> None:
monkeypatch.setattr(picker.sys, "platform", "linux")
monkeypatch.setenv("DISPLAY", ":42")
monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/test/bus")
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-reach-picker")
environment = picker._picker_environment()
assert environment["DISPLAY"] == ":42"
assert environment["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/test/bus"
assert "ANTHROPIC_API_KEY" not in environment
@pytest.mark.asyncio
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
command = _picker_command(tmp_path, "raise SystemExit(1)")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
assert await picker.pick_native_folder() is None
@pytest.mark.asyncio
async def test_pick_native_folder_rejects_non_directory_result(tmp_path, monkeypatch) -> None:
missing = tmp_path / "missing"
command = _picker_command(tmp_path, f"print({str(missing)!r}, end='')")
monkeypatch.setattr(
picker,
"_picker_command",
lambda: command,
)
with pytest.raises(picker.NativeFolderPickerError, match="invalid directory"):
await picker.pick_native_folder()
@pytest.mark.asyncio
async def test_pick_native_folder_reports_unavailable(monkeypatch) -> None:
monkeypatch.setattr(picker, "_picker_command", lambda: None)
assert picker.native_folder_picker_available() is False
with pytest.raises(picker.NativeFolderPickerError, match="unavailable"):
await picker.pick_native_folder()
@pytest.mark.asyncio
async def test_pick_native_folder_wraps_process_start_failure(tmp_path, monkeypatch) -> None:
command = _picker_command(tmp_path, "raise AssertionError('not started')")
monkeypatch.setattr(picker, "_picker_command", lambda: command)
async def fail_spawn(*args, **kwargs):
raise OSError("executable disappeared")
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", fail_spawn)
with pytest.raises(picker.NativeFolderPickerError, match="failed to start"):
await picker.pick_native_folder()
+17
View File
@@ -72,6 +72,7 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N
assert payload["default_scope"]["access_mode"] == "full" assert payload["default_scope"]["access_mode"] == "full"
assert payload["default_access_mode"] == "default" assert payload["default_access_mode"] == "default"
assert payload["controls"]["can_change_project"] is True assert payload["controls"]["can_change_project"] is True
assert payload["controls"]["can_pick_folder"] is False
def test_workspace_payload_hides_mutable_state_when_controls_unavailable( def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
@@ -91,6 +92,22 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
assert payload["default_scope"]["project_path"] == str(default.resolve()) assert payload["default_scope"]["project_path"] == str(default.resolve())
assert payload["controls"]["can_change_project"] is False assert payload["controls"]["can_change_project"] is False
assert payload["controls"]["can_use_full_access"] is False assert payload["controls"]["can_use_full_access"] is False
assert payload["controls"]["can_pick_folder"] is False
def test_workspace_payload_advertises_native_folder_picker(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
default = tmp_path / "default"
default.mkdir()
payload = workspaces_payload(
default_workspace=default,
default_restrict_to_workspace=False,
controls_available=True,
folder_picker_available=True,
)
assert payload["controls"]["can_pick_folder"] is True
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None: def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
@@ -217,6 +217,7 @@ interface ThreadComposerProps {
workspaceControls?: WorkspacesPayload["controls"] | null; workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean; workspaceScopeDisabled?: boolean;
workspaceError?: string | null; workspaceError?: string | null;
onPickWorkspaceFolder?: () => Promise<string | null>;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void; onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
pendingQueueKey?: string | null; pendingQueueKey?: string | null;
transcriptionProvider?: string | null; transcriptionProvider?: string | null;
@@ -970,6 +971,7 @@ export function ThreadComposer({
workspaceControls = null, workspaceControls = null,
workspaceScopeDisabled = false, workspaceScopeDisabled = false,
workspaceError = null, workspaceError = null,
onPickWorkspaceFolder,
onWorkspaceScopeChange, onWorkspaceScopeChange,
pendingQueueKey = null, pendingQueueKey = null,
transcriptionProvider = null, transcriptionProvider = null,
@@ -2600,6 +2602,7 @@ export function ThreadComposer({
defaultScope={workspaceDefaultScope} defaultScope={workspaceDefaultScope}
controls={workspaceControls} controls={workspaceControls}
error={workspaceError} error={workspaceError}
onPickFolder={onPickWorkspaceFolder}
onChange={onWorkspaceScopeChange} onChange={onWorkspaceScopeChange}
/> />
</div> </div>
@@ -661,6 +661,14 @@ export function ThreadShell({
forkBoundaryMessageCount, forkBoundaryMessageCount,
} = useSessionHistory(historyKey); } = useSessionHistory(historyKey);
const { client, getToken, ingressLimits, modelName, token } = useClient(); const { client, getToken, ingressLimits, modelName, token } = useClient();
const pickWorkspaceFolder = useCallback(async (): Promise<string | null> => {
const response = await client.requestMutation<{ path: unknown }>(
"workspace.pick_folder",
{},
300_000,
);
return typeof response.path === "string" ? response.path : null;
}, [client]);
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null); const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
@@ -1458,6 +1466,9 @@ export function ThreadShell({
workspaceControls={workspaceControls} workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onPickWorkspaceFolder={
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={temporary ? null : chatId} pendingQueueKey={temporary ? null : chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
@@ -1503,6 +1514,9 @@ export function ThreadShell({
workspaceControls={workspaceControls} workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled} workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError} workspaceError={workspaceError}
onPickWorkspaceFolder={
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
}
onWorkspaceScopeChange={onWorkspaceScopeChange} onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider} transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits} ingressLimits={ingressLimits}
@@ -51,6 +51,7 @@ export function WorkspaceProjectPicker({
defaultScope, defaultScope,
controls, controls,
error, error,
onPickFolder,
onChange, onChange,
}: { }: {
isHero: boolean; isHero: boolean;
@@ -61,6 +62,7 @@ export function WorkspaceProjectPicker({
defaultScope: WorkspaceScopePayload | null; defaultScope: WorkspaceScopePayload | null;
controls: WorkspacesPayload["controls"] | null; controls: WorkspacesPayload["controls"] | null;
error?: string | null; error?: string | null;
onPickFolder?: () => Promise<string | null>;
onChange?: (scope: WorkspaceScopePayload) => void; onChange?: (scope: WorkspaceScopePayload) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -79,7 +81,7 @@ export function WorkspaceProjectPicker({
&& !!defaultScope && !!defaultScope
&& !!onChange && !!onChange
&& controls?.can_change_project !== false; && controls?.can_change_project !== false;
const pickFolder = getRuntimeHost().pickFolder; const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder;
const nativeProjectPicker = !!pickFolder; const nativeProjectPicker = !!pickFolder;
useEffect(() => { useEffect(() => {
+1
View File
@@ -363,6 +363,7 @@ export interface WorkspacesPayload {
controls: { controls: {
can_change_project: boolean; can_change_project: boolean;
can_use_full_access: boolean; can_use_full_access: boolean;
can_pick_folder?: boolean;
}; };
} }
+39
View File
@@ -1260,6 +1260,45 @@ describe("ThreadComposer", () => {
})); }));
}); });
it("uses the gateway folder picker for a locally hosted WebUI", async () => {
const onWorkspaceScopeChange = vi.fn();
const pickFolder = vi.fn().mockResolvedValue("/Users/test/gateway-project");
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "full" as const,
restrict_to_workspace: false,
};
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{
can_change_project: true,
can_use_full_access: true,
can_pick_folder: true,
}}
onPickWorkspaceFolder={pickFolder}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
expect(screen.queryByLabelText("Paste path")).not.toBeInTheDocument();
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
project_path: "/Users/test/gateway-project",
project_name: "gateway-project",
access_mode: "full",
restrict_to_workspace: false,
}));
});
it("uses the web path menu when no native host picker is available", async () => { it("uses the web path menu when no native host picker is available", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const defaultScope = { const defaultScope = {