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
8 changes: 7 additions & 1 deletion packages/web-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,13 @@ export function App() {
>
<Sidebar
currentPage={page}
onNavigate={(p) => { navigate(p); setSidebarOpen(false); setKeyboardPane?.('content'); }}
onNavigate={(p) => {
navigate(p);
setSidebarOpen(false);
// Clicking the app rail claims L0 — never dump into content (that kills JK/HL).
setKeyboardPane?.('l0');
setL0FocusPageId?.(p);
}}
authUser={authUser}
collapsed={sidebar.collapsed}
onToggleCollapse={sidebar.toggle}
Expand Down
17 changes: 11 additions & 6 deletions packages/web-ui/src/components/ChatTeamSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ interface ChatTeamSidebarProps {
previewMode?: boolean;
/** Keyboard focus is on this L1 pane (H/L navigation). */
focused?: boolean;
/** When true, L from L1 enters Team L2 instead of jumping to chat content. */
l2Available?: boolean;
}

// ─── Helpers ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -215,6 +217,7 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({
initialLoading,
previewMode,
focused,
l2Available,
}: ChatTeamSidebarProps) {
const { t } = useTranslation(['team', 'common']);
const isMobile = useIsMobile();
Expand Down Expand Up @@ -1027,19 +1030,19 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({
});
}, [onSelectDm, onSelectChannel, onSelectAgent, onSelectTeam]);

// L1 keyboard: j/k move roster, H → L0, L → content (chat)
// L1 keyboard: j/k move roster, H → L0, L → L2 (if open) or content
useEffect(() => {
if (previewMode || isMobile || !isActive || hidden) return;
const onKey = (e: KeyboardEvent) => {
const pane = layout?.keyboardPane ?? 'content';
if (pane === 'l0') return;
if (pane === 'l0' || pane === 'l2') return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isEditableTarget(e.target)) return;

const bare = e.key.length === 1 ? e.key.toLowerCase() : e.key;

if (bare === 'h' || bare === 'ArrowLeft') {
if (pane !== 'l1') return; // Team.tsx handles content → L1
if (pane !== 'l1') return; // Team.tsx / L2 handle other panes
e.preventDefault();
e.stopPropagation();
layout?.setL0FocusPageId(PAGE.TEAM);
Expand All @@ -1051,7 +1054,9 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({
if (pane !== 'l1') return;
e.preventDefault();
e.stopPropagation();
layout?.setKeyboardPane('content');
// Only enter L2 when it exists. Never dump into chat content —
// deepest pane keeps JK focus (L would leave the user without JK).
if (l2Available) layout?.setKeyboardPane('l2');
return;
}

Expand Down Expand Up @@ -1088,7 +1093,7 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [
previewMode, isMobile, isActive, hidden, layout, focused,
previewMode, isMobile, isActive, hidden, layout, focused, l2Available,
chatMode, activeDmUserId, authUser?.id, selectedAgent, activeChannel, selectedTeamId,
activateL1Item,
]);
Expand All @@ -1100,7 +1105,7 @@ export const ChatTeamSidebar = memo(function ChatTeamSidebar({

return (
<>
<div className={`bg-surface-primary flex flex-col ${width != null ? 'shrink-0' : 'flex-1 min-w-0'} ${focused ? 'ring-1 ring-inset ring-brand-500/30' : ''}`} style={hidden ? { display: 'none' } : width != null ? { width } : undefined}>
<div data-keyboard-pane="l1" className={`bg-surface-primary flex flex-col ${width != null ? 'shrink-0' : 'flex-1 min-w-0'} ${focused ? 'ring-1 ring-inset ring-brand-500/30' : ''}`} style={hidden ? { display: 'none' } : width != null ? { width } : undefined}>
{/* Header with title + manage button */}
<div data-electron-drag className="px-4 h-14 flex items-center shrink-0 gap-2">
{isMobile && <MobileMenuButton />}
Expand Down
5 changes: 5 additions & 0 deletions packages/web-ui/src/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface ProjectSidebarProps {
onResizeStart?: (e: React.MouseEvent) => void;
hidden?: boolean;
focused?: boolean;
/** Called when the user pointer-activates this L1 rail (in addition to data-keyboard-pane). */
onActivate?: () => void;
}

export function ProjectSidebar({
Expand All @@ -32,13 +34,16 @@ export function ProjectSidebar({
onResizeStart,
hidden,
focused,
onActivate,
}: ProjectSidebarProps) {
const { t } = useTranslation(['work', 'common']);
const allIsSelected = allSelected ?? selectedProjectId == null;

return (
<>
<div
data-keyboard-pane="l1"
onPointerDown={() => onActivate?.()}
className="bg-surface-primary flex flex-col shrink-0 border-r border-border-default/60"
style={hidden ? { display: 'none' } : width != null ? { width } : undefined}
>
Expand Down
2 changes: 1 addition & 1 deletion packages/web-ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function Sidebar({
const { t } = useTranslation(['nav', 'common']);

return (
<aside className="markus-app-sidebar h-dvh bg-surface-secondary flex flex-col shrink-0 overflow-hidden">
<aside data-keyboard-pane="l0" className="markus-app-sidebar h-dvh bg-surface-secondary flex flex-col shrink-0 overflow-hidden">
{/* Drag region includes traffic-light clearance (padding is on this node, not aside). */}
<div
data-electron-drag
Expand Down
115 changes: 109 additions & 6 deletions packages/web-ui/src/components/TeamDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '../api.ts';
import type { AgentInfo, TeamInfo, HumanUserInfo, AuthUser } from '../api.ts';
import { Avatar } from './Avatar.tsx';
import { useLayout } from '../contexts/LayoutContext.tsx';
import { isEditableTarget } from '../lib/keyboard-shortcuts.ts';
import { PAGE } from '../routes.ts';
import { usePageActive } from '../hooks/usePageActive.ts';

type ChatMode = 'channel' | 'direct' | 'dm';

type L2NavItem =
| { kind: 'channel'; id: string; channelKey: string }
| { kind: 'agent'; id: string; agentId: string }
| { kind: 'dm'; id: string; userId: string };

interface TeamDetailPanelProps {
team: TeamInfo;
agents: AgentInfo[];
Expand All @@ -15,6 +24,7 @@ interface TeamDetailPanelProps {
chatMode: ChatMode;
selectedAgent: string;
activeChannel: string;
activeDmUserId?: string;
teams: TeamInfo[];
onSelectAgent: (agentId: string) => void;
onSelectChannel: (channelKey: string) => void;
Expand All @@ -27,18 +37,23 @@ interface TeamDetailPanelProps {
unreadByAgent?: Map<string, number>;
width?: number;
onResizeStart?: (e: React.MouseEvent) => void;
/** Keyboard focus is on this L2 pane. */
focused?: boolean;
}

export function TeamDetailPanel({
team, agents, humans, authUser, groupChat,
chatMode, selectedAgent, activeChannel,
chatMode, selectedAgent, activeChannel, activeDmUserId,
teams,
onSelectAgent, onSelectChannel, onSelectDm, onBack, onViewProfile,
onRefreshAgents, onRefreshTeams,
unreadByAgent,
width, onResizeStart,
focused,
}: TeamDetailPanelProps) {
const { t } = useTranslation(['team', 'common']);
const layout = useLayout();
const isPageActive = usePageActive(PAGE.TEAM);
const isAdmin = authUser?.role === 'owner' || authUser?.role === 'admin';

const teamAgents = useMemo(
Expand All @@ -55,6 +70,87 @@ export function TeamDetailPanel({

const isGcActive = groupChat && chatMode === 'channel' && activeChannel === groupChat.channelKey;

const l2NavItems = useMemo((): L2NavItem[] => {
const items: L2NavItem[] = [];
if (groupChat) {
items.push({ kind: 'channel', id: `channel:${groupChat.channelKey}`, channelKey: groupChat.channelKey });
}
for (const a of teamAgents) {
items.push({ kind: 'agent', id: `agent:${a.id}`, agentId: a.id });
}
for (const h of teamHumans) {
items.push({ kind: 'dm', id: `dm:${h.id}`, userId: h.id });
}
return items;
}, [groupChat, teamAgents, teamHumans]);
const l2NavItemsRef = useRef(l2NavItems);
l2NavItemsRef.current = l2NavItems;

const activateL2Item = useCallback((item: L2NavItem) => {
if (item.kind === 'channel') onSelectChannel(item.channelKey);
else if (item.kind === 'agent') onSelectAgent(item.agentId);
else onSelectDm(item.userId);
requestAnimationFrame(() => {
document.querySelector(`[data-l2-nav-id="${item.id}"]`)?.scrollIntoView({ block: 'nearest' });
});
}, [onSelectChannel, onSelectAgent, onSelectDm]);

// L2 keyboard: j/k move members, H → L1, L → content
useEffect(() => {
if (!isPageActive) return;
const onKey = (e: KeyboardEvent) => {
const pane = layout?.keyboardPane ?? 'content';
if (pane !== 'l2') return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isEditableTarget(e.target)) return;

const bare = e.key.length === 1 ? e.key.toLowerCase() : e.key;

if (bare === 'h' || bare === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
layout?.setKeyboardPane('l1');
return;
}
// L2 is the deepest Team pane — ignore L so JK focus is never lost.
if (bare === 'l' || bare === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
return;
}

const move = bare === 'j' || bare === 'ArrowDown' ? 1
: bare === 'k' || bare === 'ArrowUp' ? -1
: 0;
if (!move) return;
e.preventDefault();
e.stopPropagation();

const items = l2NavItemsRef.current;
if (items.length === 0) return;
let cur = -1;
if (chatMode === 'channel') {
cur = items.findIndex(it => it.kind === 'channel' && it.channelKey === activeChannel);
} else if (chatMode === 'direct') {
cur = items.findIndex(it => it.kind === 'agent' && it.agentId === selectedAgent);
} else if (chatMode === 'dm' && activeDmUserId) {
cur = items.findIndex(it => it.kind === 'dm' && it.userId === activeDmUserId);
}
if (cur < 0) cur = move > 0 ? -1 : 0;
const nextIdx = Math.max(0, Math.min(items.length - 1, cur + move));
activateL2Item(items[nextIdx]!);
};
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [
isPageActive, layout, chatMode, activeChannel, selectedAgent, activeDmUserId, activateL2Item,
]);

const l2SelectedClass = (isSelected: boolean) =>
isSelected
? (focused ? 'bg-brand-500/25 ring-1 ring-inset ring-brand-500/40' : 'bg-surface-overlay')
: 'hover:bg-surface-overlay/60';

// ── Agent context menu ──
const [agentMenu, setAgentMenu] = useState<{ agentId: string; x: number; y: number } | null>(null);
const [moveToOpen, setMoveToOpen] = useState(false);
Expand Down Expand Up @@ -108,7 +204,8 @@ export function TeamDetailPanel({
return (
<>
<div
className="bg-surface-primary flex flex-col shrink-0"
data-keyboard-pane="l2"
className={`bg-surface-primary flex flex-col shrink-0 ${focused ? 'ring-1 ring-inset ring-brand-500/30' : ''}`}
style={width != null ? { width } : { width: 260 }}
>
{/* Header */}
Expand Down Expand Up @@ -140,9 +237,10 @@ export function TeamDetailPanel({
{t('chat.groupChat', { defaultValue: 'Group Chat' })}
</p>
<button
data-l2-nav-id={`channel:${groupChat.channelKey}`}
onClick={() => onSelectChannel(groupChat.channelKey)}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-xs transition-colors text-fg-primary ${
isGcActive ? 'bg-surface-overlay' : 'hover:bg-surface-overlay/60'
l2SelectedClass(!!isGcActive)
}`}
>
<div className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 bg-surface-overlay text-fg-primary">
Expand Down Expand Up @@ -177,6 +275,7 @@ export function TeamDetailPanel({
return (
<button
key={a.id}
data-l2-nav-id={`agent:${a.id}`}
onClick={() => onSelectAgent(a.id)}
onContextMenu={e => {
if (!isAdmin) return;
Expand All @@ -189,7 +288,7 @@ export function TeamDetailPanel({
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-xs mb-0.5 transition-colors ${
isStopped ? 'opacity-50 text-fg-tertiary' : 'text-fg-primary'
} ${
isActive ? 'bg-surface-overlay' : 'hover:bg-surface-overlay/60'
l2SelectedClass(isActive)
}`}
>
<Avatar
Expand Down Expand Up @@ -231,11 +330,15 @@ export function TeamDetailPanel({
</p>
{teamHumans.map(h => {
const isSelf = h.id === authUser?.id;
const isDmActive = chatMode === 'dm' && activeDmUserId === h.id;
return (
<button
key={h.id}
data-l2-nav-id={`dm:${h.id}`}
onClick={() => onSelectDm(h.id)}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-xs mb-0.5 transition-colors text-fg-primary hover:bg-white/[0.08]`}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-xs mb-0.5 transition-colors text-fg-primary ${
l2SelectedClass(isDmActive)
}`}
>
<Avatar
name={h.name}
Expand Down
50 changes: 46 additions & 4 deletions packages/web-ui/src/contexts/LayoutContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export type RightPanelPayload =

export type RightPanelMode = 'browser' | 'terminal';

/** Keyboard focus zone for H/L pane navigation across L0 app rail and page L1. */
export type KeyboardPane = 'l0' | 'l1' | 'content';
/** Keyboard focus zone for H/L pane navigation: L0 app rail ↔ L1 ↔ L2 (Team) ↔ content. */
export type KeyboardPane = 'l0' | 'l1' | 'l2' | 'content';

export interface RightPanelTab {
id: string;
Expand Down Expand Up @@ -236,8 +236,8 @@ export interface LayoutContextValue {
toggleLeftCollapsed: () => void;

/**
* Keyboard focus zone: L0 app rail ↔ page L1 ↔ page content.
* H moves left (content→l1→l0); L moves right (l0→l1→content).
* Keyboard focus zone: L0 app rail ↔ page L1 ↔ L2 (when present) ↔ content.
* H moves left; L moves right.
*/
keyboardPane: KeyboardPane;
setKeyboardPane: (pane: KeyboardPane) => void;
Expand Down Expand Up @@ -317,6 +317,48 @@ export function LayoutProvider({ children }: { children: React.ReactNode }) {
const setKeyboardPane = useCallback((pane: KeyboardPane) => setKeyboardPaneState(pane), []);
const setL0FocusPageId = useCallback((pageId: string | null) => setL0FocusPageIdState(pageId), []);

// Pointer: (1) blur text fields when clicking outside so JK/HL resume without Escape;
// (2) claim a keyboard pane via [data-keyboard-pane]. Clicks outside pane regions do
// NOT clear the pane. Mark composer chrome with [data-keep-edit-focus] to keep typing.
useEffect(() => {
const isTextField = (el: Element | null): el is HTMLElement => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return el.isContentEditable;
};

const onPointerDown = (e: PointerEvent) => {
if (!(e.target instanceof Element)) return;

const active = document.activeElement;
if (isTextField(active)) {
const insideField = active === e.target || active.contains(e.target);
const keepEdit = !!e.target.closest('[data-keep-edit-focus]');
const otherField = isTextField(e.target)
|| !!e.target.closest('input, textarea, select, [contenteditable="true"]');
// Never steal focus from an embedded terminal.
const intoXterm = !!e.target.closest('.xterm');
if (!insideField && !keepEdit && !otherField && !intoXterm) {
active.blur();
}
}

const hit = e.target.closest('[data-keyboard-pane]');
if (!hit) return;
const pane = hit.getAttribute('data-keyboard-pane');
if (pane !== 'l0' && pane !== 'l1' && pane !== 'l2' && pane !== 'content') return;
setKeyboardPaneState(pane);
if (pane === 'l0') {
const pageEl = e.target.closest('[data-l0-page-id]');
const pageId = pageEl?.getAttribute('data-l0-page-id');
if (pageId) setL0FocusPageIdState(pageId);
}
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => document.removeEventListener('pointerdown', onPointerDown, true);
}, []);

const lastBrowserTabsRef = useRef<RightPanelTab[]>(initialPanel.browserTabs);
const lastBrowserActiveRef = useRef<string | null>(initialPanel.browserActiveId);
const lastTerminalTabsRef = useRef<RightPanelTab[]>(initialPanel.terminalTabs);
Expand Down
3 changes: 2 additions & 1 deletion packages/web-ui/src/lib/keyboard-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ export const KEYBOARD_SHORTCUTS: ShortcutDef[] = [
{ id: 'nav-jk-settings', group: 'navigation', keys: ['J / K'], label: 'Settings L1: switch settings tabs', labelKey: 'shortcuts.navJkSettings', page: 'any', bare: true },
{ id: 'nav-hl-work', group: 'navigation', keys: ['H / L'], label: 'Tasks: item list ↔ project L1 ↔ app rail', labelKey: 'shortcuts.navHlWork', page: 'work', bare: true },
{ id: 'nav-jk-work', group: 'navigation', keys: ['J / K'], label: 'Tasks: move project / item selection', labelKey: 'shortcuts.navJkWork', page: 'work', bare: true },
{ id: 'nav-hl-team', group: 'navigation', keys: ['H / L'], label: 'Team: chatroster L1 ↔ app rail', labelKey: 'shortcuts.navHlTeam', page: 'team', bare: true },
{ id: 'nav-hl-team', group: 'navigation', keys: ['H / L'], label: 'Team: L0 ↔ L1 ↔ L2 (L ignored on deepest pane)', labelKey: 'shortcuts.navHlTeam', page: 'team', bare: true },
{ id: 'nav-jk-team', group: 'navigation', keys: ['J / K'], label: 'Team L1: move roster selection', labelKey: 'shortcuts.navJkTeam', page: 'team', bare: true },
{ id: 'nav-jk-team-l2', group: 'navigation', keys: ['J / K'], label: 'Team L2: move team member selection', labelKey: 'shortcuts.navJkTeamL2', page: 'team', bare: true },
{ id: 'nav-jk-deliverables', group: 'navigation', keys: ['J / K'], label: 'Output L1: move deliverable selection', labelKey: 'shortcuts.navJkDeliverables', page: 'deliverables', bare: true },
{ id: 'nav-jk-store', group: 'navigation', keys: ['J / K'], label: 'Store L1: switch store tabs', labelKey: 'shortcuts.navJkStore', page: 'store', bare: true },

Expand Down
Loading
Loading