From 1471c6754e691f337be6a471523c10f9051cdda0 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Tue, 10 Nov 2026 16:25:43 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(web):=20switch=20orgs=20from=20the=20?= =?UTF-8?q?=E2=8C=98K=20palette=20with=20scope=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - List organizations in the ⌘K palette, with server search past the first 500 memberships - Add scope chips (All, Organizations, Projects, Chats & tasks, Connections, Actions); Tab and Shift+Tab cycle them - Make the rail search button open the palette; keep the org-only dialog for /site-editor, where the palette is not mounted - Move org switching (rail pin, recents, navigation) into a shared useTravelToOrg hook used by both the rail and the palette --- apps/web/src/components/command-palette.tsx | 391 +++++++++++++----- apps/web/src/components/sidebar/org-rail.tsx | 278 ++++++++----- .../web/src/components/sidebar/org-search.tsx | 134 +++--- apps/web/src/hooks/use-travel-to-org.ts | 35 ++ apps/web/src/i18n/en/command-palette.ts | 9 + apps/web/src/i18n/en/sidebar.ts | 2 +- apps/web/src/i18n/pt-br/command-palette.ts | 9 + apps/web/src/i18n/pt-br/sidebar.ts | 2 +- 8 files changed, 565 insertions(+), 295 deletions(-) create mode 100644 apps/web/src/hooks/use-travel-to-org.ts diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index a25f902643c..8feae5cab9e 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -7,7 +7,7 @@ * * Two kinds of result, deliberately separated: * - * LOCAL — destinations, projects, settings pages, actions. Already in + * LOCAL — orgs, destinations, projects, settings pages, actions. Already in * memory, so they appear on the first keystroke with no spinner and * work offline. Every label goes through `t()`. * REMOTE — threads and tasks, via GLOBAL_SEARCH. Deferred, so typing never @@ -41,6 +41,8 @@ import { CommandItem, CommandList, } from "@decocms/ui/components/command.tsx"; +import { cn } from "@decocms/ui/lib/utils.ts"; +import { OrgIcon } from "@/components/header/org-switcher"; import { IntegrationIcon } from "@/components/integration-icon"; import { ProjectIcon } from "@/components/project-icon"; import { @@ -50,6 +52,12 @@ import { import { DESTINATION_ROUTE } from "@/hooks/use-destination-route"; import { useProjectScope } from "@/hooks/use-project-scope"; import { useRecentProjects } from "@/hooks/use-recent-projects"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { useOrganizationSearch } from "@/hooks/use-organization-search"; +import { useRecentOrgs } from "@/hooks/use-recent-orgs"; +import { useTravelToOrg } from "@/hooks/use-travel-to-org"; +import { useActiveOrganizations } from "@/lib/auth-client"; +import { withCurrentOrg } from "@/lib/recent-orgs"; import { useT } from "@/i18n/use-t.ts"; import { track } from "@/lib/posthog-client"; import { @@ -87,7 +95,62 @@ type SearchHit = /** The palette lists a few best matches; the threads-panel dialog pages. */ const PALETTE_LIMIT = 8; -function useGlobalSearch(term: string): SearchHit[] { +const SCOPES = [ + "all", + "organizations", + "projects", + "chats", + "connections", + "actions", +] as const; +type Scope = (typeof SCOPES)[number]; + +/** Orgs shown under "All" before anything is typed; the chip lists them all. */ +const ORG_PREVIEW_LIMIT = 5; + +interface PaletteOrg { + id: string; + name: string; + slug: string; + logo?: string | null; +} + +function scopeLabelKey(scope: Scope) { + switch (scope) { + case "all": + return "commandPalette.scope.all" as const; + case "organizations": + return "commandPalette.organizations" as const; + case "projects": + return "commandPalette.projects" as const; + case "chats": + return "commandPalette.scope.chats" as const; + case "connections": + return "commandPalette.connections" as const; + case "actions": + return "commandPalette.actions" as const; + default: { + const exhaustive: never = scope; + return exhaustive; + } + } +} + +/** Recent first (the order the rail keeps), then the rest by name. */ +function orderOrgs( + orgs: readonly PaletteOrg[], + recent: readonly string[], +): PaletteOrg[] { + const rank = (slug: string) => { + const at = recent.indexOf(slug); + return at === -1 ? Number.POSITIVE_INFINITY : at; + }; + return [...orgs].sort( + (a, b) => rank(a.slug) - rank(b.slug) || a.name.localeCompare(b.name), + ); +} + +function useGlobalSearch(term: string, enabled: boolean): SearchHit[] { const { org } = useProjectContext(); /** Non-blocking on purpose: the palette mounts on ⌘K, outside every Suspense * boundary but the root, so suspending on the self-MCP connect (uncached on @@ -101,7 +164,7 @@ function useGlobalSearch(term: string): SearchHit[] { const { data } = useQuery({ queryKey: KEYS.globalSearch(org.id, term, PALETTE_LIMIT), - enabled: !!client && term.trim().length >= 2, + enabled: enabled && !!client && term.trim().length >= 2, staleTime: 15_000, retry: false, queryFn: async () => { @@ -134,12 +197,49 @@ export function CommandPalette({ const { projects, hasProjects } = useProjectScope(); const { remember: rememberProject } = useRecentProjects(org.slug); const [term, setTerm] = useState(""); + const [scope, setScope] = useState("all"); + const shows = (kind: Scope) => scope === "all" || scope === kind; const deferredTerm = useDeferredValue(term); - const hits = useGlobalSearch(deferredTerm); + const hits = useGlobalSearch( + deferredTerm, + shows("chats") || shows("connections"), + ); + + /** Orgs: the in-memory list, plus the server's matches past Better Auth's + * first 500 memberships once something is typed. */ + const travelToOrg = useTravelToOrg(); + const { data: organizations } = useActiveOrganizations(); + const { recent: recentOrgs } = useRecentOrgs(); + const typed = term.trim(); + const orgTerm = useDebouncedValue(typed, 150); + const { hits: orgHits } = useOrganizationSearch( + shows("organizations") ? orgTerm : "", + ); + const memberOrgs = orderOrgs( + withCurrentOrg((organizations ?? []) as PaletteOrg[], org), + recentOrgs, + ); + const serverOrgIds = new Set(orgHits.map((hit) => hit.id)); + const allOrgs = [ + ...memberOrgs, + ...orgHits.filter((hit) => !memberOrgs.some((o) => o.id === hit.id)), + ]; + /** Under "All" with nothing typed, a short list of the OTHER orgs — the one + * you are in is not where you are going. */ + const paletteOrgs = + scope === "all" && !typed + ? allOrgs.filter((it) => it.slug !== org.slug).slice(0, ORG_PREVIEW_LIMIT) + : allOrgs; const close = () => { onOpenChange(false); setTerm(""); + setScope("all"); + }; + + const cycleScope = (step: 1 | -1) => { + const at = SCOPES.indexOf(scope); + setScope(SCOPES[(at + step + SCOPES.length) % SCOPES.length] ?? "all"); }; const go = (fn: () => void, what: string) => { @@ -255,36 +355,106 @@ export function CommandPalette({ description={t("commandPalette.description")} > { + if (event.key === "Tab") { + event.preventDefault(); + cycleScope(event.shiftKey ? -1 : 1); + return; + } + /** Backspace on an empty field steps out of the scope first. */ + if (event.key === "Backspace" && !term && scope !== "all") { + event.preventDefault(); + setScope("all"); + } + }} /> - +
+ {SCOPES.map((option) => ( + + ))} +
+ {t("commandPalette.empty")} - - {destinations.map((destination) => ( - - go( - () => - navigate({ - to: destination.to, - params: destination.params, - }), - "destination", - ) - } - > - {destination.icon} - {destination.label} - - ))} - + {shows("organizations") && paletteOrgs.length > 0 && ( + + {paletteOrgs.map((candidate) => ( + + go(() => travelToOrg(candidate.slug), "organization") + } + > + + + {candidate.name} + + {candidate.slug === org.slug && ( + + {t("sidebar.rail.currentOrganization")} + + )} + + ))} + + )} + + {shows("actions") && ( + + {destinations.map((destination) => ( + + go( + () => + navigate({ + to: destination.to, + params: destination.params, + }), + "destination", + ) + } + > + {destination.icon} + {destination.label} + + ))} + + )} - {hasProjects && ( + {shows("projects") && hasProjects && ( {projects.map((project) => ( /** Opens the workspace rather than setting the scope: "go to" is @@ -317,75 +487,77 @@ export function CommandPalette({ )} - - - go( - () => - navigate({ - to: DESTINATION_ROUTE.home, - params: orgParams, - }), - "new_project", - ) - } - > - - - go( - () => - navigate({ - to: "/$org/settings/members", - params: orgParams, - }), - "invite", - ) - } - > - - - go( - () => - navigate({ - to: "/$org/settings/connections", - params: orgParams, - search: { tab: "all" as const }, - }), - "add_connection", - ) - } - > - - - go( - () => - navigate({ - to: "/$org/settings/general", - params: orgParams, - }), - "settings", - ) - } - > - - + {shows("actions") && ( + + + go( + () => + navigate({ + to: DESTINATION_ROUTE.home, + params: orgParams, + }), + "new_project", + ) + } + > + + + go( + () => + navigate({ + to: "/$org/settings/members", + params: orgParams, + }), + "invite", + ) + } + > + + + go( + () => + navigate({ + to: "/$org/settings/connections", + params: orgParams, + search: { tab: "all" as const }, + }), + "add_connection", + ) + } + > + + + go( + () => + navigate({ + to: "/$org/settings/general", + params: orgParams, + }), + "settings", + ) + } + > + + + )} - {connectionHits.length > 0 && ( + {shows("connections") && connectionHits.length > 0 && ( {connectionHits.map((hit) => ( )} - {resultHits.length > 0 && ( + {shows("chats") && resultHits.length > 0 && ( {resultHits.map((hit) => ( )} +
+ + + + +
); } + +function PaletteHint({ keys, label }: { keys: string[]; label: string }) { + return ( + + + {keys.map((key) => ( + + {key} + + ))} + + {label} + + ); +} diff --git a/apps/web/src/components/sidebar/org-rail.tsx b/apps/web/src/components/sidebar/org-rail.tsx index 074644c2f85..a79103880f3 100644 --- a/apps/web/src/components/sidebar/org-rail.tsx +++ b/apps/web/src/components/sidebar/org-rail.tsx @@ -16,7 +16,14 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { EyeOff, Pin01, Plus, Settings01, XClose } from "@untitledui/icons"; +import { + EyeOff, + Pin01, + Plus, + SearchLg, + Settings01, + XClose, +} from "@untitledui/icons"; import { Link, useMatchRoute, @@ -46,6 +53,7 @@ import { useOpenApp, useRememberOpenApp } from "@/hooks/use-recent-apps"; import { useRecentProjects } from "@/hooks/use-recent-projects"; import { useProjectScope, useScopeId } from "@/hooks/use-project-scope"; import { useRecentOrgs } from "@/hooks/use-recent-orgs"; +import { useTravelToOrg } from "@/hooks/use-travel-to-org"; import { useLocalStorage } from "@/hooks/use-local-storage"; import { LOCALSTORAGE_KEYS } from "@/lib/localstorage-keys"; import { railOrgLimit, railOrgs, withCurrentOrg } from "@/lib/recent-orgs"; @@ -55,11 +63,12 @@ import { hideRailOrg, moveRailOrg, readRailOrgPrefs, - showRailOrg, toggleRailOrgPin, type RailOrgPrefs, } from "@/lib/rail-org-prefs"; -import { OrgSearch } from "./org-search"; +import { OrgSearchDialog } from "./org-search"; +import { openCommandPalette } from "@/components/command-palette-store"; +import { KEYBOARD_SHORTCUTS } from "@/lib/keyboard-shortcuts"; import { RailItem } from "./rail-item"; import { useProjectContext } from "@/sdk"; import { useT } from "@/i18n/use-t.ts"; @@ -317,6 +326,9 @@ export function OrgRail({ onNavigate }: { onNavigate?: () => void } = {}) { const activeSlug = travelling ? travelling.org : currentOrg.slug; const { data: organizations } = useActiveOrganizations(); const [creatingOrg, setCreatingOrg] = useState(false); + /** Outside any org the palette is not mounted, so search falls back to + * the org-only dialog. */ + const [searchingOrgs, setSearchingOrgs] = useState(false); /** The border separates the rail from the sidebar; collapsed, there is none. */ const collapsed = useSidebarCollapsed(); /** The projects you opened here, under the org's mark, newest first. */ @@ -331,7 +343,8 @@ export function OrgRail({ onNavigate }: { onNavigate?: () => void } = {}) { : recentIds.flatMap((id) => projects.filter((p) => p.id === id)); const orgs = withCurrentOrg((organizations ?? []) as RailOrg[], currentOrg); - const { recent: recentOrgs, remember } = useRecentOrgs(); + const { recent: recentOrgs } = useRecentOrgs(); + const travel = useTravelToOrg(); /** Hidden, pinned and dragged order. Hidden orgs stay in search, and * opening one from there puts it back. */ const [storedPrefs, setPrefs] = useLocalStorage( @@ -375,126 +388,169 @@ export function OrgRail({ onNavigate }: { onNavigate?: () => void } = {}) { const travelTo = (slug: string) => { track("org_rail_travel"); - /** An org already on the rail keeps the rail as it is; one picked from - * search rolls the oldest off by recency, as before. */ - const onRail = shown.some((it) => it.slug === slug); - setPrefs((prev) => showRailOrg(readRailOrgPrefs(prev), slug)); - remember(slug, onRail ? shown.map((it) => it.slug) : undefined); - goHome(slug); + onNavigate?.(); + travel( + slug, + shown.map((it) => it.slug), + ); + }; + + const openSearch = () => { + track("org_rail_search_opened"); + if (!currentOrg.id) { + setSearchingOrgs(true); + return; + } + onNavigate?.(); + openCommandPalette(); }; return ( <>
- - it.slug)} - strategy={verticalListSortingStrategy} + - {shown.map((candidate) => ( - - travelTo(candidate.slug) - : scopeId - ? () => goHome(candidate.slug) - : undefined - } - onOpenSettings={() => { - onNavigate?.(); - navigate({ - to: "/$org/settings", - params: { org: candidate.slug }, - }); - }} - onTogglePin={() => - setPrefs((prev) => - toggleRailOrgPin(readRailOrgPrefs(prev), candidate.slug), - ) - } - onHide={ - candidate.slug === currentOrg.slug - ? undefined - : () => - setPrefs((prev) => - hideRailOrg(readRailOrgPrefs(prev), candidate.slug), - ) - } - /> - {candidate.slug === currentOrg.slug && - recentProjects.length > 0 && ( -
- {recentProjects.map((project) => ( - forget(project.id)} - onNavigate={onNavigate} - /> - ))} -
- )} -
- ))} -
-
- {orgs.length > shown.length && ( - - )} - - - - - - - {t("sidebar.picker.newOrganization")} - - - - {localApp && ( - <> - {/* A rule and not a gap: below it the mark stops meaning "an org + it.slug)} + strategy={verticalListSortingStrategy} + > + {shown.map((candidate) => ( + + travelTo(candidate.slug) + : scopeId + ? () => goHome(candidate.slug) + : undefined + } + onOpenSettings={() => { + onNavigate?.(); + navigate({ + to: "/$org/settings", + params: { org: candidate.slug }, + }); + }} + onTogglePin={() => + setPrefs((prev) => + toggleRailOrgPin( + readRailOrgPrefs(prev), + candidate.slug, + ), + ) + } + onHide={ + candidate.slug === currentOrg.slug + ? undefined + : () => + setPrefs((prev) => + hideRailOrg( + readRailOrgPrefs(prev), + candidate.slug, + ), + ) + } + /> + {candidate.slug === currentOrg.slug && + recentProjects.length > 0 && ( +
+ {recentProjects.map((project) => ( + forget(project.id)} + onNavigate={onNavigate} + /> + ))} +
+ )} +
+ ))} +
+ + {localApp && ( + <> + {/* A rule and not a gap: below it the mark stops meaning "an org you belong to" and starts meaning "a thing you had open". */} - - - - )} + + + + )} +
+ {/* Pinned under the scrolling marks, so they stay put however many orgs + the rail draws. */} +
+ + + + + + + {t("sidebar.rail.search")} + + {KEYBOARD_SHORTCUTS.commandPalette.keys.join("")} + + + + + + + + + + + {t("sidebar.picker.newOrganization")} + + + +
+
+ ); } diff --git a/apps/web/src/hooks/use-travel-to-org.ts b/apps/web/src/hooks/use-travel-to-org.ts new file mode 100644 index 00000000000..d0005aa365f --- /dev/null +++ b/apps/web/src/hooks/use-travel-to-org.ts @@ -0,0 +1,35 @@ +/** Switching org from the rail or the ⌘K palette: bring the org back onto the + * rail if it was hidden, record it as recent, and go to its home. */ + +import { useNavigate } from "@tanstack/react-router"; +import { useLocalStorage } from "@/hooks/use-local-storage"; +import { useRecentOrgs } from "@/hooks/use-recent-orgs"; +import { LOCALSTORAGE_KEYS } from "@/lib/localstorage-keys"; +import { + EMPTY_RAIL_ORG_PREFS, + readRailOrgPrefs, + showRailOrg, + type RailOrgPrefs, +} from "@/lib/rail-org-prefs"; + +export function useTravelToOrg(): ( + slug: string, + /** The slugs the rail shows now. An org already among them keeps the rail + * as it is; one picked from search rolls the oldest off by recency. */ + railSlugs?: readonly string[], +) => void { + const navigate = useNavigate(); + const { remember } = useRecentOrgs(); + const [, setPrefs] = useLocalStorage( + LOCALSTORAGE_KEYS.railOrgPrefs(), + EMPTY_RAIL_ORG_PREFS, + ); + + return (slug, railSlugs) => { + const onRail = railSlugs?.includes(slug) ?? false; + setPrefs((prev) => showRailOrg(readRailOrgPrefs(prev), slug)); + remember(slug, onRail ? railSlugs : undefined); + /** A view transition, so the rail's marks slide to their new places. */ + navigate({ to: "/$org/home", params: { org: slug }, viewTransition: true }); + }; +} diff --git a/apps/web/src/i18n/en/command-palette.ts b/apps/web/src/i18n/en/command-palette.ts index 80347bec68b..3432a686463 100644 --- a/apps/web/src/i18n/en/command-palette.ts +++ b/apps/web/src/i18n/en/command-palette.ts @@ -12,4 +12,13 @@ export const commandPalette = { "commandPalette.inviteTeammate": "Invite a teammate", "commandPalette.addConnection": "Add a connection", "commandPalette.settings": "Settings", + "commandPalette.placeholderScoped": "Search {scope}\u2026", + "commandPalette.organizations": "Organizations", + "commandPalette.scope.label": "Filter results", + "commandPalette.scope.all": "All", + "commandPalette.scope.chats": "Chats & tasks", + "commandPalette.hint.navigate": "Navigate", + "commandPalette.hint.open": "Open", + "commandPalette.hint.filter": "Filter", + "commandPalette.hint.close": "Close", } as const; diff --git a/apps/web/src/i18n/en/sidebar.ts b/apps/web/src/i18n/en/sidebar.ts index 8e86790c5dd..9ebd8cfb1ba 100644 --- a/apps/web/src/i18n/en/sidebar.ts +++ b/apps/web/src/i18n/en/sidebar.ts @@ -67,7 +67,7 @@ export const sidebar = { "sidebar.rail.unpinOrganization": "Unpin from sidebar", "sidebar.rail.searchEmpty": "No organization matches that.", "sidebar.rail.searchFailed": "Could not search organizations.", - "sidebar.rail.searchMoreOrganizations": "Search organizations ({count} more)", + "sidebar.rail.search": "Search", "sidebar.rail.searchOrganizations": "Search organizations", "sidebar.rail.searchPlaceholder": "Search by name or URL", "sidebar.showMoreButton.ariaLabel": "Show more tasks", diff --git a/apps/web/src/i18n/pt-br/command-palette.ts b/apps/web/src/i18n/pt-br/command-palette.ts index 7f52ebddadd..32dad7f6296 100644 --- a/apps/web/src/i18n/pt-br/command-palette.ts +++ b/apps/web/src/i18n/pt-br/command-palette.ts @@ -15,4 +15,13 @@ export const commandPalette = { "commandPalette.inviteTeammate": "Convidar algu\u00e9m do time", "commandPalette.addConnection": "Adicionar uma conex\u00e3o", "commandPalette.settings": "Configura\u00e7\u00f5es", + "commandPalette.placeholderScoped": "Pesquisar {scope}\u2026", + "commandPalette.organizations": "Organiza\u00e7\u00f5es", + "commandPalette.scope.label": "Filtrar resultados", + "commandPalette.scope.all": "Tudo", + "commandPalette.scope.chats": "Chats e tarefas", + "commandPalette.hint.navigate": "Navegar", + "commandPalette.hint.open": "Abrir", + "commandPalette.hint.filter": "Filtrar", + "commandPalette.hint.close": "Fechar", } satisfies Record; diff --git a/apps/web/src/i18n/pt-br/sidebar.ts b/apps/web/src/i18n/pt-br/sidebar.ts index c1450a54839..5627b04201d 100644 --- a/apps/web/src/i18n/pt-br/sidebar.ts +++ b/apps/web/src/i18n/pt-br/sidebar.ts @@ -71,7 +71,7 @@ export const sidebar = { "sidebar.rail.unpinOrganization": "Desafixar da barra lateral", "sidebar.rail.searchEmpty": "Nenhuma organização corresponde.", "sidebar.rail.searchFailed": "Não foi possível buscar organizações.", - "sidebar.rail.searchMoreOrganizations": "Buscar organizações (mais {count})", + "sidebar.rail.search": "Pesquisar", "sidebar.rail.searchOrganizations": "Buscar organizações", "sidebar.rail.searchPlaceholder": "Busque por nome ou URL", "sidebar.showMoreButton.ariaLabel": "Mostrar mais tarefas", From fbbc77babe4c353d84537b003a05fc4120382d66 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Tue, 10 Nov 2026 16:51:03 -0300 Subject: [PATCH 2/2] fix(web): name the rail's search button apart from page search buttons The rail's always-visible "Search" button shared its accessible name with the connections page's search toggle, so a click on "Search" could open the palette instead of the connection search. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/i18n/en/sidebar.ts | 2 +- apps/web/src/i18n/pt-br/sidebar.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/i18n/en/sidebar.ts b/apps/web/src/i18n/en/sidebar.ts index 9ebd8cfb1ba..315b6fbf406 100644 --- a/apps/web/src/i18n/en/sidebar.ts +++ b/apps/web/src/i18n/en/sidebar.ts @@ -67,7 +67,7 @@ export const sidebar = { "sidebar.rail.unpinOrganization": "Unpin from sidebar", "sidebar.rail.searchEmpty": "No organization matches that.", "sidebar.rail.searchFailed": "Could not search organizations.", - "sidebar.rail.search": "Search", + "sidebar.rail.search": "Search everything", "sidebar.rail.searchOrganizations": "Search organizations", "sidebar.rail.searchPlaceholder": "Search by name or URL", "sidebar.showMoreButton.ariaLabel": "Show more tasks", diff --git a/apps/web/src/i18n/pt-br/sidebar.ts b/apps/web/src/i18n/pt-br/sidebar.ts index 5627b04201d..263c680581b 100644 --- a/apps/web/src/i18n/pt-br/sidebar.ts +++ b/apps/web/src/i18n/pt-br/sidebar.ts @@ -71,7 +71,7 @@ export const sidebar = { "sidebar.rail.unpinOrganization": "Desafixar da barra lateral", "sidebar.rail.searchEmpty": "Nenhuma organização corresponde.", "sidebar.rail.searchFailed": "Não foi possível buscar organizações.", - "sidebar.rail.search": "Pesquisar", + "sidebar.rail.search": "Pesquisar tudo", "sidebar.rail.searchOrganizations": "Buscar organizações", "sidebar.rail.searchPlaceholder": "Busque por nome ou URL", "sidebar.showMoreButton.ariaLabel": "Mostrar mais tarefas",