Phase 4: power UX (palette, shortcuts, mentions, mobile, PWA)

Command palette (cmd+K) with fuzzy nav, ticket search, people lookup
and action entries (new ticket, logout, show shortcuts). Opens from
keyboard or user dropdown.

Global keyboard shortcuts via a small useShortcut/useLeaderShortcut
hook: `?` help overlay, `c` new ticket, `g d|t|m|n|s` leader nav.
Tickets list: j/k cursor, Enter open, x toggle select. TicketDetail:
`e` edit, `r` focus comment composer. All guarded against firing
inside text fields.

@mention autocomplete in the comment composer (MentionTextarea) with
arrow-key nav and Tab/Enter insert. Rendered comments and audit log
rewrite @username tokens to links pointing at that user's assignee
filter; unknown usernames left as plain text.

Mobile sweep: TicketDetail sidebar stacks below content on <md,
Settings profile grid collapses to one column, admin tables get
horizontal scroll with a 640px min width, CTI 3-column grid stacks
vertically on <md, New ticket severity/assignee grid same.

PWA: manifest.webmanifest, SVG icon, minimal network-first service
worker for the app shell (never caches /api/*), registered in
production builds only. Theme-color meta + manifest link in index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 16:36:28 -04:00
parent 4bade22410
commit ef22e92ac8
21 changed files with 976 additions and 28 deletions
+39 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
@@ -69,6 +70,7 @@ function sevColor(severity: number) {
export default function Tickets() {
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
@@ -83,6 +85,7 @@ export default function Tickets() {
const [searchInput, setSearchInput] = useState(search);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [cursor, setCursor] = useState<number>(-1);
const [saveOpen, setSaveOpen] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [confirmBulk, setConfirmBulk] = useState<
@@ -213,6 +216,35 @@ export default function Tickets() {
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
// Keyboard navigation
useEffect(() => {
setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c));
}, [tickets.length]);
useShortcut('j', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1));
}, [tickets.length]);
useShortcut('k', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1));
}, [tickets.length]);
useShortcut('enter', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
navigate(`/${tickets[cursor].displayId}`);
}, [cursor, tickets]);
useShortcut('x', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
toggleOne(tickets[cursor].id);
}, [cursor, tickets]);
return (
<Layout wide>
{/* Status tabs */}
@@ -453,10 +485,14 @@ export default function Tickets() {
</div>
<ul className="divide-y divide-border">
{tickets.map((ticket) => (
{tickets.map((ticket, idx) => (
<li
key={ticket.id}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors"
className={`flex items-center gap-3 px-4 py-3 transition-colors ${
idx === cursor
? 'bg-accent/50 ring-1 ring-inset ring-primary'
: 'hover:bg-accent/30'
}`}
>
<input
type="checkbox"