Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,16 @@ already in use` and no way forward short of finding the file in a temporary

### Desktop / Web UI

- **The Work tabs stay out of the way until you use sandboxes** — every desktop
install showed a **Code** / **Work** switch above the projects list, even
though Work was empty for anyone who had never made a sandbox: switching to it
offered to build one and otherwise had nothing to show. The switch now appears
once you actually have a sandbox — created from Settings → Servers → **+** →
**Add sandbox** — and Work behaves exactly as before from then on. Delete your
last sandbox and it steps back out of the way again. If you want Work visible
before creating anything, Settings → General → Advanced has a **Show Work**
switch.
([#292](https://github.com/quickbeard/codev-code/pull/292))
- **Files first in the `@` mention menu** — the composer's `@` popup listed
references, agents, and MCP resources ahead of files, so the top match Enter
would accept was rarely the file being typed. File results (recent open tabs
Expand Down
4 changes: 3 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions packages/app/src/components/settings-v2/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,25 @@ export const SettingsGeneralV2: Component<{
/>
</div>
</SettingsRowV2>

{/* Fork: sandboxes are opt-in, so this reveals the Work side before the
first one exists. Owning a sandbox shows Work regardless, which is
why the row says "even with no sandboxes" rather than promising to
hide it. Sandbox-capable builds only — elsewhere there is no Work
side to reveal. */}
<Show when={platform.sandboxes}>
<SettingsRowV2
title={language.t("settings.general.row.showWork.title")}
description={language.t("settings.general.row.showWork.description")}
>
<div data-action="settings-show-work">
<Switch
checked={settings.general.showWork()}
onChange={(checked) => settings.general.setShowWork(checked)}
/>
</div>
</SettingsRowV2>
</Show>
</SettingsListV2>
</div>
)
Expand Down
8 changes: 8 additions & 0 deletions packages/app/src/context/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface Settings {
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showCustomAgents: boolean
showWork: boolean
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
Expand Down Expand Up @@ -147,6 +148,9 @@ const defaultSettings: Settings = {
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showCustomAgents: false,
// Sandboxes are opt-in: a profile with none has no Work side and no
// Code/Work strip until it makes one, or turns this on to go looking.
showWork: false,
mobileTitlebarPosition: "top",
},
appearance: {
Expand Down Expand Up @@ -308,6 +312,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
showWork: withFallback(() => store.general?.showWork, defaultSettings.general.showWork),
setShowWork(value: boolean) {
setStore("general", "showWork", value)
},
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,9 @@ export const dict = {
"settings.general.row.showCustomAgents.title": "Show agent",
"settings.general.row.showCustomAgents.description":
"Switch between agents in the composer. When hidden, defaults to Build agent.",
"settings.general.row.showWork.title": "Show Work",
"settings.general.row.showWork.description":
"Keep the Code/Work tabs visible even with no sandboxes. Creating a sandbox shows Work either way.",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -951,6 +951,9 @@ export const dict = {
"Colocar la barra de título y las pestañas de sesión en la parte inferior de la pantalla en dispositivos móviles",
"settings.general.row.showCustomAgents.title": "Agentes personalizados",
"settings.general.row.showCustomAgents.description": "Mostrar el selector de agentes en el editor",
"settings.general.row.showWork.title": "Mostrar Trabajo",
"settings.general.row.showWork.description":
"Mantener visibles las pestañas Código/Trabajo aunque no haya entornos aislados. Crear uno muestra Trabajo de todas formas.",
"settings.general.row.reasoningSummaries.title": "Mostrar resúmenes de razonamiento",
"settings.general.row.reasoningSummaries.description":
"Mostrar resúmenes del razonamiento del modelo en la línea de tiempo",
Expand Down
40 changes: 30 additions & 10 deletions packages/app/src/workspace/side.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,37 @@ export type WorkspaceSide = "code" | "work"

export const WORKSPACE_SIDES = ["code", "work"] as const

// Sandboxes are a desktop feature, so the web build has no Work side at all.
// A persisted "work" from a desktop session must not strand the sidebar on a
// side that platform can never populate.
// A persisted "work" from a build or a profile that has no Work side must not
// strand the sidebar there. Pass workSideAvailable()'s answer, not raw platform
// support: with Work hidden the tab strip is gone, so a stranded side would
// leave the user on sandbox servers with no way back to Code.
export function effectiveSide(side: WorkspaceSide, workAvailable: boolean): WorkspaceSide {
return workAvailable ? side : "code"
}

// Whether the Work side exists for this user at all — it gates both the side
// itself and the Code/Work tab strip, which is furniture with only one side to
// pick.
//
// Deliberately not also collapsing when there are zero sandboxes: the sandbox
// list arrives asynchronously, so "none" and "not loaded yet" look identical
// here, and treating them alike bounced the user back to Code both on every
// launch and in the beat after creating their first sandbox. An empty Work
// side is a legible state — WorkspaceEmpty says so and offers to create one.
export function effectiveSide(side: WorkspaceSide, sandboxesSupported: boolean): WorkspaceSide {
return sandboxesSupported ? side : "code"
// Sandboxes are opt-in: a profile that has never made one sees no Work side and
// no strip, and reaches the feature through Settings → Servers → + → Add
// sandbox (or the preference below). Owning a sandbox shows Work whatever the
// preference says — hiding the side its servers live on would strand them.
//
// `sandboxes` is undefined until the list arrives over IPC, which reads the
// same as "none" but must not be treated as one: collapsing on unknown bounced
// the sidebar to Code on every launch and in the beat after the first sandbox
// was created. Unknown keeps Work, and the real answer lands a round trip later
// — the main process answers getState() synchronously, so that window is
// sub-frame in practice.
export function workSideAvailable(input: {
sandboxesSupported: boolean
preference: boolean
sandboxes: { config: { id: string } }[] | undefined
}) {
if (!input.sandboxesSupported) return false
if (input.preference) return true
return !input.sandboxes || input.sandboxes.length > 0
}

// The sandbox key format (sandbox:<name>) is persisted in the main-process
Expand Down
30 changes: 24 additions & 6 deletions packages/app/src/workspace/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { showToast } from "@/utils/toast"
import { useSandboxes } from "@/sandbox/context"
import { isPlaceholderSandbox } from "@/sandbox/projects"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import {
effectiveSide,
folderEntry,
Expand All @@ -22,6 +23,7 @@ import {
selectionForSide,
sideForServer,
WORKSPACE_SIDES,
workSideAvailable,
workspaceEmptyState,
type TabScope,
type WorkspaceSide,
Expand Down Expand Up @@ -58,12 +60,27 @@ function defaultFolder(
return folderForDirectory(ctx.projects.last(), folders) ?? folders[0]
}

// Whether this profile has a Work side at all — see workSideAvailable. Gates
// both the side and the Code/Work strip, so the two can never disagree.
export function useWorkAvailable() {
const platform = usePlatform()
const settings = useSettings()
const sandboxes = useSandboxes()
return createMemo(() =>
workSideAvailable({
sandboxesSupported: !!platform.sandboxes,
preference: settings.general.showWork(),
sandboxes: sandboxes.state.data?.sandboxes,
}),
)
}

// The side everything should actually render, as opposed to the side that
// happens to be persisted. Read this rather than layout.home.workspace().
export function useWorkspaceSide() {
const layout = useLayout()
const platform = usePlatform()
return createMemo(() => effectiveSide(layout.home.workspace(), !!platform.sandboxes))
const available = useWorkAvailable()
return createMemo(() => effectiveSide(layout.home.workspace(), available()))
}

// Where selecting a mounted folder sends you. Its own hook rather than part of
Expand Down Expand Up @@ -375,15 +392,16 @@ export function WorkspaceEmpty(props: { side: WorkspaceSide }) {

export function WorkspaceTabs(props: { class?: string }) {
const language = useLanguage()
const platform = usePlatform()
const workspace = useWorkspaceSwitch()
const available = useWorkAvailable()

const label = (side: WorkspaceSide) => language.t(side === "code" ? "workspace.code" : "workspace.work")

// Sandboxes are desktop-only, so on web there is no second side and a lone
// "Code" tab would be furniture.
// No Work side — web, or a desktop profile that has never made a sandbox and
// has not opted in — leaves one side to pick, and a lone "Code" tab is
// furniture.
return (
<Show when={platform.sandboxes}>
<Show when={available()}>
<div
data-component="workspace-tabs"
role="tablist"
Expand Down
44 changes: 36 additions & 8 deletions packages/app/test-browser/workspace-side.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
sideForServer,
tabsForScope,
tabsForSide,
workSideAvailable,
workspaceEmptyState,
type TabScope,
} from "@/workspace/side"
Expand Down Expand Up @@ -112,19 +113,46 @@ test("ignores a reorder that does not cover the whole side", () => {
expect(reorderWithinSide(tabs, "code", [tabKey(tabs[0])])).toEqual(tabs.map(tabKey))
})

// Sandboxes are desktop-only, so a "work" side persisted on the desktop must
// not strand the web build's sidebar on a side it can never populate.
test("collapses to Code where sandboxes are unsupported", () => {
// A "work" side persisted where Work is not available must not strand the
// sidebar on a side whose tab strip is hidden — there would be no way back.
test("collapses to Code where Work is unavailable", () => {
expect(effectiveSide("work", true)).toBe("work")
expect(effectiveSide("work", false)).toBe("code")
expect(effectiveSide("code", false)).toBe("code")
})

// An empty Work side stays selected: the sandbox list loads asynchronously, so
// collapsing on "no sandboxes" also fires before the list arrives and in the
// beat after the first one is created.
test("keeps Work selected even with no sandboxes", () => {
expect(effectiveSide("work", true)).toBe("work")
// Sandboxes are opt-in: a profile that has never made one gets no Work side and
// no Code/Work strip until it does, or turns the preference on.
test("hides Work for a sandbox-less profile that has not opted in", () => {
expect(workSideAvailable({ sandboxesSupported: true, preference: false, sandboxes: [] })).toBe(false)
expect(workSideAvailable({ sandboxesSupported: true, preference: true, sandboxes: [] })).toBe(true)
})

// Owning a sandbox shows Work whatever the preference says: hiding the side its
// servers live on would strand them.
test("shows Work once a sandbox exists, preference or not", () => {
expect(
workSideAvailable({ sandboxesSupported: true, preference: false, sandboxes: [{ config: { id: "sandbox:a" } }] }),
).toBe(true)
expect(
workSideAvailable({ sandboxesSupported: true, preference: true, sandboxes: [{ config: { id: "sandbox:a" } }] }),
).toBe(true)
})

// The web build has no sandbox platform at all, so neither the preference nor a
// stale list can conjure a Work side.
test("never shows Work where sandboxes are unsupported", () => {
expect(
workSideAvailable({ sandboxesSupported: false, preference: true, sandboxes: [{ config: { id: "sandbox:a" } }] }),
).toBe(false)
expect(workSideAvailable({ sandboxesSupported: false, preference: false, sandboxes: undefined })).toBe(false)
})

// An undefined list is "not loaded yet", not "none": collapsing on it bounced
// the sidebar to Code on every launch and in the beat after the first sandbox
// was created.
test("keeps Work while the sandbox list has not arrived", () => {
expect(workSideAvailable({ sandboxesSupported: true, preference: false, sandboxes: undefined })).toBe(true)
})

// Answered from the list as it stands before the removal: the sandbox list
Expand Down
Loading