@hienlh/ppm 0.8.60 → 0.8.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/web/assets/{_basePickBy-CZovQgWd.js → _basePickBy-COwDPZl_.js} +1 -1
- package/dist/web/assets/{_baseUniq-ClnvscgW.js → _baseUniq-DCb0mkTp.js} +1 -1
- package/dist/web/assets/{api-settings--eVrUeZM.js → api-settings-CuUkz5gb.js} +1 -1
- package/dist/web/assets/{arc-C2Qaz-ch.js → arc-D0bJaFyD.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-281eTKQ3.js +1 -0
- package/dist/web/assets/{architectureDiagram-2XIMDMQ5-Jq91S_rs.js → architectureDiagram-2XIMDMQ5-BVEUkQYB.js} +1 -1
- package/dist/web/assets/arrow-left-C_j9Ki73.js +1 -0
- package/dist/web/assets/{blockDiagram-WCTKOSBZ-CKGufRTy.js → blockDiagram-WCTKOSBZ-CU2t4NHJ.js} +1 -1
- package/dist/web/assets/browser-tab-BnHjUFD1.js +1 -0
- package/dist/web/assets/{c4Diagram-IC4MRINW-BNP2L9r_.js → c4Diagram-IC4MRINW-DzjR91sM.js} +1 -1
- package/dist/web/assets/channel-CKNZAqoN.js +1 -0
- package/dist/web/assets/chat-tab-CyWueJTv.js +7 -0
- package/dist/web/assets/{chunk-4BX2VUAB-BptTlTyl.js → chunk-4BX2VUAB-0YMkpW2S.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-C4mUdyio.js → chunk-55IACEB6-Dp0pTM5r.js} +1 -1
- package/dist/web/assets/{chunk-7E7YKBS2-6xAQfBwa.js → chunk-7E7YKBS2-CuYKSUgJ.js} +1 -1
- package/dist/web/assets/{chunk-7R4GIKGN-DXaGAn_K.js → chunk-7R4GIKGN-DvbvLUIN.js} +2 -2
- package/dist/web/assets/{chunk-C72U2L5F-DOtEiN5f.js → chunk-C72U2L5F-CcEW1AMZ.js} +1 -1
- package/dist/web/assets/{chunk-EGIJ26TM-D0KJTa_T.js → chunk-EGIJ26TM-Cgt-qg75.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-C_1aG0eb.js → chunk-FMBD7UC4-JCLgVcaC.js} +1 -1
- package/dist/web/assets/{chunk-GEFDOKGD-DwVPiYfW.js → chunk-GEFDOKGD-B82RP9ow.js} +1 -1
- package/dist/web/assets/chunk-GLR3WWYH-Bx2UL5jF.js +2 -0
- package/dist/web/assets/chunk-HHEYEP7N-BnRVfNc5.js +1 -0
- package/dist/web/assets/{chunk-JSJVCQXG-BSrqCL_3.js → chunk-JSJVCQXG-Pb-JMOgO.js} +1 -1
- package/dist/web/assets/{chunk-KX2RTZJC-BCxGmbzy.js → chunk-KX2RTZJC-BRj-ZEvL.js} +1 -1
- package/dist/web/assets/{chunk-KYZI473N-BKO5gMeU.js → chunk-KYZI473N-CBRPKraG.js} +1 -1
- package/dist/web/assets/{chunk-L3YUKLVL-3wBgkSvL.js → chunk-L3YUKLVL-DNFj84V6.js} +1 -1
- package/dist/web/assets/{chunk-MX3YWQON-BgjSEzus.js → chunk-MX3YWQON-BnPzQK-O.js} +1 -1
- package/dist/web/assets/{chunk-NQ4KR5QH-DLrZwBEm.js → chunk-NQ4KR5QH-BRj25yO7.js} +1 -1
- package/dist/web/assets/{chunk-O4XLMI2P-BurQy8tt.js → chunk-O4XLMI2P-BdXwVXjJ.js} +1 -1
- package/dist/web/assets/{chunk-OZEHJAEY-YTn24bGg.js → chunk-OZEHJAEY-LfXT4p8B.js} +1 -1
- package/dist/web/assets/{chunk-PQ6SQG4A-BxtUGYhW.js → chunk-PQ6SQG4A-EdgQyTqa.js} +1 -1
- package/dist/web/assets/{chunk-PU5JKC2W-B66ELkQm.js → chunk-PU5JKC2W-D3thuSok.js} +1 -1
- package/dist/web/assets/chunk-QZHKN3VN-gaBt0Rbd.js +1 -0
- package/dist/web/assets/{chunk-R5LLSJPH-euR2RxLN.js → chunk-R5LLSJPH-LdG7RqsM.js} +1 -1
- package/dist/web/assets/{chunk-WL4C6EOR-_2CBOJdI.js → chunk-WL4C6EOR-BHFnnXOt.js} +1 -1
- package/dist/web/assets/{chunk-XIRO2GV7-kqQ0g6wW.js → chunk-XIRO2GV7-DUmQrLsF.js} +1 -1
- package/dist/web/assets/{chunk-XPW4576I-CtcaMb09.js → chunk-XPW4576I-CsGTseUr.js} +1 -1
- package/dist/web/assets/{chunk-XZSTWKYB-BYxFzZwS.js → chunk-XZSTWKYB-5W2emiq4.js} +1 -1
- package/dist/web/assets/{chunk-YBOYWFTD-Dx_fX35n.js → chunk-YBOYWFTD-COdZIaX4.js} +1 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-CqaIqYPn.js +1 -0
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bo5WN2ok.js +1 -0
- package/dist/web/assets/clone-DNDy9Sms.js +1 -0
- package/dist/web/assets/{code-editor-DMw26mUm.js → code-editor-DLXTYEm2.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-CHHjH2dV.js → cose-bilkent-S5V4N54A-C1QJ6GPW.js} +1 -1
- package/dist/web/assets/{dagre-CNtSxiE_.js → dagre-CWo8w9wK.js} +1 -1
- package/dist/web/assets/{dagre-KLK3FWXG-ChenfPp1.js → dagre-KLK3FWXG-Br4t5TRV.js} +1 -1
- package/dist/web/assets/database-viewer-eqHDuoj7.js +1 -0
- package/dist/web/assets/{diagram-E7M64L7V-CzKYZM0Y.js → diagram-E7M64L7V-CkDC2uAj.js} +1 -1
- package/dist/web/assets/{diagram-IFDJBPK2-ChB_paPo.js → diagram-IFDJBPK2-NvhckwcA.js} +1 -1
- package/dist/web/assets/{diagram-P4PSJMXO-D1eW1dkL.js → diagram-P4PSJMXO--nUaNiyB.js} +1 -1
- package/dist/web/assets/{diff-viewer-DVqfhdBN.js → diff-viewer-DFwFZ_k5.js} +1 -1
- package/dist/web/assets/{erDiagram-INFDFZHY-mCvUFSn6.js → erDiagram-INFDFZHY-DK4QEZYh.js} +1 -1
- package/dist/web/assets/{flowDiagram-PKNHOUZH-14ohZ1M1.js → flowDiagram-PKNHOUZH-B9h_Ba-v.js} +1 -1
- package/dist/web/assets/{ganttDiagram-A5KZAMGK-DIX0pLbk.js → ganttDiagram-A5KZAMGK-BVlftqyZ.js} +1 -1
- package/dist/web/assets/git-graph-Fu6M3rOo.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-D5qEPjgs.js +1 -0
- package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-yEWZbdf_.js → gitGraphDiagram-K3NZZRJ6-L7sj3Bs-.js} +1 -1
- package/dist/web/assets/{graphlib-DhOZxqsh.js → graphlib-BbbiUImY.js} +1 -1
- package/dist/web/assets/index-Bf4IsWu9.js +37 -0
- package/dist/web/assets/index-n0Ww6i6b.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-CbpovIYU.js +1 -0
- package/dist/web/assets/infoDiagram-LFFYTUFH-DFh9c-S2.js +2 -0
- package/dist/web/assets/input-DGlv6gt_.js +41 -0
- package/dist/web/assets/{isEmpty-C0YYdhYj.js → isEmpty-DXomfd7J.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-PHBUUO56-olazD6dZ.js → ishikawaDiagram-PHBUUO56-cW7SMLa_.js} +1 -1
- package/dist/web/assets/{journeyDiagram-4ABVD52K-CttDH9bb.js → journeyDiagram-4ABVD52K-DFQXUZsc.js} +1 -1
- package/dist/web/assets/{kanban-definition-K7BYSVSG-BBXbI37U.js → kanban-definition-K7BYSVSG-BMUhjxqj.js} +1 -1
- package/dist/web/assets/keybindings-store-BTg2T4RA.js +1 -0
- package/dist/web/assets/{line-DBLLF7lH.js → line--xyfYP3x.js} +1 -1
- package/dist/web/assets/{linear-BLFWatDe.js → linear-BdqW7iQu.js} +1 -1
- package/dist/web/assets/{markdown-renderer--Ss7hHOm.js → markdown-renderer-B7o8ysmw.js} +5 -5
- package/dist/web/assets/{mermaid-parser.core-BKiGOTjR.js → mermaid-parser.core-BY8JfkE_.js} +2 -2
- package/dist/web/assets/{mindmap-definition-YRQLILUH-DoT7m4Sz.js → mindmap-definition-YRQLILUH-DIv-LMXG.js} +1 -1
- package/dist/web/assets/{ordinal-CCj7PWgZ.js → ordinal-CIoJK3nc.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-BbzPU9BK.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-B0h6hM1j.js +1 -0
- package/dist/web/assets/{pieDiagram-SKSYHLDU-Bkh2E4zE.js → pieDiagram-SKSYHLDU-seSK40d1.js} +1 -1
- package/dist/web/assets/postgres-viewer-BMJBkwN7.js +1 -0
- package/dist/web/assets/{quadrantDiagram-337W2JSQ-B7zgALOL.js → quadrantDiagram-337W2JSQ-BaRFqlsA.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-CHptMqVT.js +1 -0
- package/dist/web/assets/{requirementDiagram-Z7DCOOCP-D_5GXNRo.js → requirementDiagram-Z7DCOOCP-1WWjMQB_.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-BA9EFAAe.js → sankeyDiagram-WA2Y5GQK-DEGGYsk7.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-2WXFIKYE-fyWIrHiG.js → sequenceDiagram-2WXFIKYE-BtRvoUTC.js} +1 -1
- package/dist/web/assets/{settings-store-Bbhg_ptG.js → settings-store-D3dJqGhB.js} +2 -2
- package/dist/web/assets/settings-tab-D4FbV-Xi.js +1 -0
- package/dist/web/assets/sqlite-viewer-BIdCcD9_.js +1 -0
- package/dist/web/assets/{stateDiagram-RAJIS63D-DfRBcaBu.js → stateDiagram-RAJIS63D-C16aO8tn.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-D7qSAjnK.js +1 -0
- package/dist/web/assets/switch-goUjvGec.js +1 -0
- package/dist/web/assets/{tab-store-DcIBZTD4.js → tab-store-DSz5PQI0.js} +1 -1
- package/dist/web/assets/{terminal-tab--Ag9kqvS.js → terminal-tab-1CtxESHt.js} +1 -1
- package/dist/web/assets/{timeline-definition-YZTLITO2-DYfwJ1jM.js → timeline-definition-YZTLITO2-DrjxCpEM.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-BL9OJq3X.js +1 -0
- package/dist/web/assets/{use-monaco-theme-DHbyUrzJ.js → use-monaco-theme-BQzvItNE.js} +1 -1
- package/dist/web/assets/{vennDiagram-LZ73GAT5-DqbKNRD9.js → vennDiagram-LZ73GAT5-DfYFnniI.js} +1 -1
- package/dist/web/assets/{xychartDiagram-JWTSCODW-DhUL86qT.js → xychartDiagram-JWTSCODW-BRvXOVlG.js} +1 -1
- package/dist/web/index.html +12 -10
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +106 -7
- package/docs/project-changelog.md +80 -1
- package/docs/streaming-input-guide.md +267 -0
- package/docs/system-architecture.md +177 -12
- package/package.json +1 -1
- package/src/server/index.ts +4 -0
- package/src/server/routes/browser-preview.ts +89 -0
- package/src/web/components/browser/browser-tab.tsx +269 -0
- package/src/web/components/chat/message-input.tsx +68 -2
- package/src/web/components/layout/command-palette.tsx +4 -0
- package/src/web/components/layout/editor-panel.tsx +1 -0
- package/src/web/components/layout/mobile-nav.tsx +2 -2
- package/src/web/components/layout/tab-bar.tsx +2 -0
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/hooks/use-global-keybindings.ts +7 -0
- package/src/web/hooks/use-voice-input.ts +111 -0
- package/src/web/stores/keybindings-store.ts +1 -0
- package/src/web/stores/tab-store.ts +2 -1
- package/dist/web/assets/architecture-PBZL5I3N-ChOahOB7.js +0 -1
- package/dist/web/assets/channel-w7yboq56.js +0 -1
- package/dist/web/assets/chat-tab-C5H74y2z.js +0 -7
- package/dist/web/assets/chunk-GLR3WWYH-D9pZakqr.js +0 -2
- package/dist/web/assets/chunk-HHEYEP7N-Dld5BpGB.js +0 -1
- package/dist/web/assets/chunk-QZHKN3VN-DwSXwtjH.js +0 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-BpJ6Oog2.js +0 -1
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-Bj8gIhkP.js +0 -1
- package/dist/web/assets/clone-BSi6cgDh.js +0 -1
- package/dist/web/assets/database-viewer-gnj_8u4T.js +0 -1
- package/dist/web/assets/git-graph-CJy7tOAJ.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-CEee2FCA.js +0 -1
- package/dist/web/assets/index-BAioKo_2.css +0 -2
- package/dist/web/assets/index-Dg6TQ3Iu.js +0 -37
- package/dist/web/assets/info-3K5VOQVL-ce_pi3En.js +0 -1
- package/dist/web/assets/infoDiagram-LFFYTUFH-BzqyoqXw.js +0 -2
- package/dist/web/assets/input-Brjz2Vv-.js +0 -41
- package/dist/web/assets/keybindings-store-DcxZ6WAa.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-CdYSLjRL.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-Bm5LiD-6.js +0 -1
- package/dist/web/assets/postgres-viewer-DMcvp0H7.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-C4PnyG7_.js +0 -1
- package/dist/web/assets/settings-tab-lC12I-a1.js +0 -1
- package/dist/web/assets/sqlite-viewer-BK2emL4i.js +0 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CMN4M2Em.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-2_y-mhkz.js +0 -1
- /package/dist/web/assets/{api-client-DpGMOZNf.js → api-client-icCZ-07C.js} +0 -0
- /package/dist/web/assets/{array-BGFCBI0e.js → array-CLwNaqU1.js} +0 -0
- /package/dist/web/assets/{columns-2-ChOTgl3e.js → columns-2-Bcg3QJBg.js} +0 -0
- /package/dist/web/assets/{cytoscape.esm-Ccan6xou.js → cytoscape.esm-B-QQuWwK.js} +0 -0
- /package/dist/web/assets/{defaultLocale-CRZydyG6.js → defaultLocale-D_VMtRaY.js} +0 -0
- /package/dist/web/assets/{dist-T0Vhi0Mh.js → dist-CMmNEgEP.js} +0 -0
- /package/dist/web/assets/{dist-Cce3efmT.js → dist-Ckxnw5rl.js} +0 -0
- /package/dist/web/assets/{init-B8gtcn7T.js → init-vVpfz1D6.js} +0 -0
- /package/dist/web/assets/{isArrayLikeObject-B4pdpV8V.js → isArrayLikeObject-DvHDmeBe.js} +0 -0
- /package/dist/web/assets/{katex-Bbu770d9.js → katex-C3cZrCvP.js} +0 -0
- /package/dist/web/assets/{math-DwgHI-Cu.js → math-a44lmFDa.js} +0 -0
- /package/dist/web/assets/{path-DZF-JdEe.js → path-CuyvWNAH.js} +0 -0
- /package/dist/web/assets/{preload-helper-qlgyTAkD.js → preload-helper-CsoeaaUJ.js} +0 -0
- /package/dist/web/assets/{react-BGf7KNLk.js → react-BPIfZRKM.js} +0 -0
- /package/dist/web/assets/{rough.esm-VLpapkIG.js → rough.esm-c4PR5shF.js} +0 -0
- /package/dist/web/assets/{src-BoSBNdA_.js → src-CLWraeNW.js} +0 -0
- /package/dist/web/assets/{table-Yo02WRH-.js → table-C9jDaRl2.js} +0 -0
- /package/dist/web/assets/{tag-CaC1ng2E.js → tag-CENGyt_L.js} +0 -0
- /package/dist/web/assets/{utils-btZ8C8-R.js → utils-Bslrbb-G.js} +0 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ArrowLeft,
|
|
4
|
+
ArrowRight,
|
|
5
|
+
RotateCcw,
|
|
6
|
+
ExternalLink,
|
|
7
|
+
Globe,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
10
|
+
|
|
11
|
+
/** Parse a URL string — returns normalized URL or null if invalid */
|
|
12
|
+
function parseUrl(input: string): string | null {
|
|
13
|
+
let url = input.trim();
|
|
14
|
+
if (!url) return null;
|
|
15
|
+
|
|
16
|
+
// If just a port number, treat as localhost
|
|
17
|
+
if (/^\d+$/.test(url)) return `http://localhost:${url}`;
|
|
18
|
+
|
|
19
|
+
// If host:port without scheme, add http://
|
|
20
|
+
if (/^localhost(:\d+)?/.test(url)) url = `http://${url}`;
|
|
21
|
+
if (/^[\w.-]+:\d+/.test(url) && !url.includes("://")) url = `http://${url}`;
|
|
22
|
+
|
|
23
|
+
// If no scheme at all, add https:// for external, http:// for localhost
|
|
24
|
+
if (!url.includes("://")) {
|
|
25
|
+
url = url.includes("localhost") ? `http://${url}` : `https://${url}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
new URL(url);
|
|
30
|
+
return url;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Check if a URL is a localhost address */
|
|
37
|
+
function isLocalhost(url: string): boolean {
|
|
38
|
+
try {
|
|
39
|
+
const u = new URL(url);
|
|
40
|
+
return (
|
|
41
|
+
u.hostname === "localhost" ||
|
|
42
|
+
u.hostname === "127.0.0.1" ||
|
|
43
|
+
u.hostname === "0.0.0.0" ||
|
|
44
|
+
u.hostname === "::1"
|
|
45
|
+
);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Convert URL to iframe src — proxy localhost through backend */
|
|
52
|
+
function toIframeSrc(url: string): string {
|
|
53
|
+
if (!isLocalhost(url)) return url;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const u = new URL(url);
|
|
57
|
+
const port = u.port || "80";
|
|
58
|
+
const path = u.pathname + u.search + u.hash;
|
|
59
|
+
return `/api/preview/${port}${path}`;
|
|
60
|
+
} catch {
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Extract display URL from iframe src (reverse of toIframeSrc) */
|
|
66
|
+
function fromIframeSrc(src: string): string {
|
|
67
|
+
const match = src.match(/^\/api\/preview\/(\d+)(\/.*)?$/);
|
|
68
|
+
if (match) {
|
|
69
|
+
const port = match[1];
|
|
70
|
+
const path = match[2] || "/";
|
|
71
|
+
return `http://localhost:${port}${path}`;
|
|
72
|
+
}
|
|
73
|
+
return src;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface BrowserTabProps {
|
|
77
|
+
metadata?: Record<string, unknown>;
|
|
78
|
+
tabId?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function BrowserTab({ metadata, tabId }: BrowserTabProps) {
|
|
82
|
+
const initialUrl = (metadata?.url as string) || "http://localhost:3000";
|
|
83
|
+
const [addressBar, setAddressBar] = useState(initialUrl);
|
|
84
|
+
const [currentUrl, setCurrentUrl] = useState(initialUrl);
|
|
85
|
+
const [iframeSrc, setIframeSrc] = useState(toIframeSrc(initialUrl));
|
|
86
|
+
const [canGoBack, setCanGoBack] = useState(false);
|
|
87
|
+
const [canGoForward, setCanGoForward] = useState(false);
|
|
88
|
+
const [loading, setLoading] = useState(true);
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
91
|
+
const updateTab = useTabStore((s) => s.updateTab);
|
|
92
|
+
|
|
93
|
+
// Navigation history (iframe same-origin only)
|
|
94
|
+
const historyRef = useRef<string[]>([initialUrl]);
|
|
95
|
+
const historyIdxRef = useRef(0);
|
|
96
|
+
|
|
97
|
+
const navigate = useCallback(
|
|
98
|
+
(url: string, addToHistory = true) => {
|
|
99
|
+
const parsed = parseUrl(url);
|
|
100
|
+
if (!parsed) {
|
|
101
|
+
setError("Invalid URL");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setError(null);
|
|
106
|
+
setCurrentUrl(parsed);
|
|
107
|
+
setAddressBar(parsed);
|
|
108
|
+
setIframeSrc(toIframeSrc(parsed));
|
|
109
|
+
setLoading(true);
|
|
110
|
+
|
|
111
|
+
if (addToHistory) {
|
|
112
|
+
const h = historyRef.current;
|
|
113
|
+
const idx = historyIdxRef.current;
|
|
114
|
+
// Truncate forward history
|
|
115
|
+
historyRef.current = h.slice(0, idx + 1);
|
|
116
|
+
historyRef.current.push(parsed);
|
|
117
|
+
historyIdxRef.current = historyRef.current.length - 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setCanGoBack(historyIdxRef.current > 0);
|
|
121
|
+
setCanGoForward(
|
|
122
|
+
historyIdxRef.current < historyRef.current.length - 1,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Update tab title
|
|
126
|
+
if (tabId) {
|
|
127
|
+
try {
|
|
128
|
+
const u = new URL(parsed);
|
|
129
|
+
const title = isLocalhost(parsed)
|
|
130
|
+
? `localhost:${u.port || "80"}`
|
|
131
|
+
: u.hostname;
|
|
132
|
+
updateTab(tabId, { title });
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
[tabId, updateTab],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const goBack = useCallback(() => {
|
|
140
|
+
if (historyIdxRef.current > 0) {
|
|
141
|
+
historyIdxRef.current--;
|
|
142
|
+
navigate(historyRef.current[historyIdxRef.current]!, false);
|
|
143
|
+
}
|
|
144
|
+
}, [navigate]);
|
|
145
|
+
|
|
146
|
+
const goForward = useCallback(() => {
|
|
147
|
+
if (historyIdxRef.current < historyRef.current.length - 1) {
|
|
148
|
+
historyIdxRef.current++;
|
|
149
|
+
navigate(historyRef.current[historyIdxRef.current]!, false);
|
|
150
|
+
}
|
|
151
|
+
}, [navigate]);
|
|
152
|
+
|
|
153
|
+
const reload = useCallback(() => {
|
|
154
|
+
setLoading(true);
|
|
155
|
+
setError(null);
|
|
156
|
+
if (iframeRef.current) {
|
|
157
|
+
// Force reload by re-setting src
|
|
158
|
+
const src = iframeRef.current.src;
|
|
159
|
+
iframeRef.current.src = "";
|
|
160
|
+
requestAnimationFrame(() => {
|
|
161
|
+
if (iframeRef.current) iframeRef.current.src = src;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const openExternal = useCallback(() => {
|
|
167
|
+
window.open(currentUrl, "_blank");
|
|
168
|
+
}, [currentUrl]);
|
|
169
|
+
|
|
170
|
+
const handleAddressKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
171
|
+
if (e.key === "Enter") {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
navigate(addressBar);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Navigate when metadata.url changes (e.g. opened from command palette)
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const metaUrl = metadata?.url as string | undefined;
|
|
180
|
+
if (metaUrl && metaUrl !== currentUrl) {
|
|
181
|
+
navigate(metaUrl);
|
|
182
|
+
}
|
|
183
|
+
}, [metadata?.url]);
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="flex flex-col h-full w-full bg-background">
|
|
187
|
+
{/* Toolbar */}
|
|
188
|
+
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border bg-surface shrink-0">
|
|
189
|
+
{/* Nav buttons */}
|
|
190
|
+
<button
|
|
191
|
+
onClick={goBack}
|
|
192
|
+
disabled={!canGoBack}
|
|
193
|
+
className="p-1.5 rounded hover:bg-surface-elevated disabled:opacity-30 transition-colors"
|
|
194
|
+
title="Back"
|
|
195
|
+
>
|
|
196
|
+
<ArrowLeft className="size-4" />
|
|
197
|
+
</button>
|
|
198
|
+
<button
|
|
199
|
+
onClick={goForward}
|
|
200
|
+
disabled={!canGoForward}
|
|
201
|
+
className="p-1.5 rounded hover:bg-surface-elevated disabled:opacity-30 transition-colors"
|
|
202
|
+
title="Forward"
|
|
203
|
+
>
|
|
204
|
+
<ArrowRight className="size-4" />
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
onClick={reload}
|
|
208
|
+
className="p-1.5 rounded hover:bg-surface-elevated transition-colors"
|
|
209
|
+
title="Reload"
|
|
210
|
+
>
|
|
211
|
+
<RotateCcw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
212
|
+
</button>
|
|
213
|
+
|
|
214
|
+
{/* Address bar */}
|
|
215
|
+
<div className="flex-1 flex items-center gap-2 mx-1 px-2.5 py-1.5 rounded-md bg-background border border-border focus-within:border-accent/50 transition-colors">
|
|
216
|
+
<Globe className="size-3.5 text-text-subtle shrink-0" />
|
|
217
|
+
<input
|
|
218
|
+
type="text"
|
|
219
|
+
value={addressBar}
|
|
220
|
+
onChange={(e) => setAddressBar(e.target.value)}
|
|
221
|
+
onKeyDown={handleAddressKeyDown}
|
|
222
|
+
placeholder="Enter URL or port (e.g. 3000, localhost:8080)"
|
|
223
|
+
className="flex-1 bg-transparent text-xs text-text-primary outline-none placeholder:text-text-subtle min-w-0"
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Open external */}
|
|
228
|
+
<button
|
|
229
|
+
onClick={openExternal}
|
|
230
|
+
className="p-1.5 rounded hover:bg-surface-elevated transition-colors"
|
|
231
|
+
title="Open in browser"
|
|
232
|
+
>
|
|
233
|
+
<ExternalLink className="size-4" />
|
|
234
|
+
</button>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Content */}
|
|
238
|
+
<div className="flex-1 relative min-h-0">
|
|
239
|
+
{error ? (
|
|
240
|
+
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
|
241
|
+
<p>{error}</p>
|
|
242
|
+
</div>
|
|
243
|
+
) : (
|
|
244
|
+
<iframe
|
|
245
|
+
ref={iframeRef}
|
|
246
|
+
src={iframeSrc}
|
|
247
|
+
className="w-full h-full border-0"
|
|
248
|
+
sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-modals"
|
|
249
|
+
onLoad={() => setLoading(false)}
|
|
250
|
+
onError={() => {
|
|
251
|
+
setLoading(false);
|
|
252
|
+
setError(`Failed to load ${currentUrl}`);
|
|
253
|
+
}}
|
|
254
|
+
/>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Loading overlay */}
|
|
258
|
+
{loading && !error && (
|
|
259
|
+
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
|
260
|
+
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
|
261
|
+
<RotateCcw className="size-4 animate-spin" />
|
|
262
|
+
<span>Loading...</span>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, useEffect, memo, type KeyboardEvent, type DragEvent, type ClipboardEvent } from "react";
|
|
2
|
-
import { ArrowUp, Square, Paperclip, Loader2 } from "lucide-react";
|
|
2
|
+
import { ArrowUp, Square, Paperclip, Loader2, Mic, MicOff } from "lucide-react";
|
|
3
|
+
import { useVoiceInput } from "@/hooks/use-voice-input";
|
|
3
4
|
import { api, projectUrl, getAuthToken } from "@/lib/api-client";
|
|
4
5
|
import { randomId } from "@/lib/utils";
|
|
5
6
|
import { isSupportedFile, isImageFile } from "@/lib/file-support";
|
|
@@ -74,6 +75,41 @@ export const MessageInput = memo(function MessageInput({
|
|
|
74
75
|
const slashItemsRef = useRef<SlashItem[]>([]);
|
|
75
76
|
const fileItemsRef = useRef<FileNode[]>([]);
|
|
76
77
|
|
|
78
|
+
// Voice input (Web Speech API)
|
|
79
|
+
const voice = useVoiceInput();
|
|
80
|
+
// Store pre-voice text so voice appends to existing input
|
|
81
|
+
const preVoiceTextRef = useRef("");
|
|
82
|
+
const voiceResultCb = useCallback((text: string) => {
|
|
83
|
+
const prefix = preVoiceTextRef.current;
|
|
84
|
+
const newValue = prefix ? prefix + " " + text : text;
|
|
85
|
+
setValue(newValue);
|
|
86
|
+
// Auto-resize textarea
|
|
87
|
+
requestAnimationFrame(() => {
|
|
88
|
+
const ta = window.matchMedia("(min-width: 768px)").matches
|
|
89
|
+
? textareaRef.current
|
|
90
|
+
: mobileTextareaRef.current;
|
|
91
|
+
if (ta) {
|
|
92
|
+
ta.style.height = "auto";
|
|
93
|
+
ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}, []);
|
|
97
|
+
const handleVoiceToggle = useCallback(() => {
|
|
98
|
+
if (voice.isListening) {
|
|
99
|
+
voice.stop();
|
|
100
|
+
} else {
|
|
101
|
+
preVoiceTextRef.current = value.trim();
|
|
102
|
+
voice.start(voiceResultCb);
|
|
103
|
+
}
|
|
104
|
+
}, [voice.isListening, voice.start, voice.stop, value, voiceResultCb]);
|
|
105
|
+
|
|
106
|
+
// Listen for global keyboard shortcut (Cmd+Shift+V) to toggle voice
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const handler = () => { if (voice.supported) handleVoiceToggle(); };
|
|
109
|
+
window.addEventListener("toggle-voice-input", handler);
|
|
110
|
+
return () => window.removeEventListener("toggle-voice-input", handler);
|
|
111
|
+
}, [voice.supported, handleVoiceToggle]);
|
|
112
|
+
|
|
77
113
|
// Apply initialValue when it changes (e.g. "Ask AI" from command palette)
|
|
78
114
|
useEffect(() => {
|
|
79
115
|
if (initialValue) {
|
|
@@ -465,7 +501,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
465
501
|
onOpenChange={setModeSelectorOpen}
|
|
466
502
|
/>
|
|
467
503
|
</div>
|
|
468
|
-
{/* Mobile: single row — attach + textarea + send */}
|
|
504
|
+
{/* Mobile: single row — attach + mic + textarea + send */}
|
|
469
505
|
<div className="flex items-end gap-1 md:hidden px-2 py-2">
|
|
470
506
|
<button
|
|
471
507
|
type="button"
|
|
@@ -476,6 +512,21 @@ export const MessageInput = memo(function MessageInput({
|
|
|
476
512
|
>
|
|
477
513
|
<Paperclip className="size-4" />
|
|
478
514
|
</button>
|
|
515
|
+
{voice.supported && (
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
onClick={(e) => { e.stopPropagation(); handleVoiceToggle(); }}
|
|
519
|
+
disabled={disabled}
|
|
520
|
+
className={`flex items-center justify-center size-7 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
|
|
521
|
+
voice.isListening
|
|
522
|
+
? "bg-red-600 text-white animate-pulse"
|
|
523
|
+
: "text-text-subtle hover:text-text-primary"
|
|
524
|
+
}`}
|
|
525
|
+
aria-label={voice.isListening ? "Stop voice input" : "Start voice input"}
|
|
526
|
+
>
|
|
527
|
+
{voice.isListening ? <MicOff className="size-4" /> : <Mic className="size-4" />}
|
|
528
|
+
</button>
|
|
529
|
+
)}
|
|
479
530
|
<textarea
|
|
480
531
|
ref={mobileTextareaRef}
|
|
481
532
|
value={value}
|
|
@@ -535,6 +586,21 @@ export const MessageInput = memo(function MessageInput({
|
|
|
535
586
|
>
|
|
536
587
|
<Paperclip className="size-4" />
|
|
537
588
|
</button>
|
|
589
|
+
{voice.supported && (
|
|
590
|
+
<button
|
|
591
|
+
type="button"
|
|
592
|
+
onClick={(e) => { e.stopPropagation(); handleVoiceToggle(); }}
|
|
593
|
+
disabled={disabled}
|
|
594
|
+
className={`flex items-center justify-center size-8 rounded-full transition-colors disabled:opacity-50 ${
|
|
595
|
+
voice.isListening
|
|
596
|
+
? "bg-red-600 text-white animate-pulse"
|
|
597
|
+
: "text-text-subtle hover:text-text-primary hover:bg-surface-elevated"
|
|
598
|
+
}`}
|
|
599
|
+
aria-label={voice.isListening ? "Stop voice input" : "Start voice input"}
|
|
600
|
+
>
|
|
601
|
+
{voice.isListening ? <MicOff className="size-4" /> : <Mic className="size-4" />}
|
|
602
|
+
</button>
|
|
603
|
+
)}
|
|
538
604
|
{/* Mode indicator chip */}
|
|
539
605
|
<div className="relative">
|
|
540
606
|
<ModeChip
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
FileCode,
|
|
11
11
|
FolderOpen,
|
|
12
12
|
Loader2,
|
|
13
|
+
Globe,
|
|
14
|
+
Mic,
|
|
13
15
|
} from "lucide-react";
|
|
14
16
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
15
17
|
import { useProjectStore } from "@/stores/project-store";
|
|
@@ -156,7 +158,9 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
156
158
|
{ id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action", shortcut: formatShortcut(getBinding("open-chat")) },
|
|
157
159
|
{ id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action", shortcut: formatShortcut(getBinding("open-terminal")) },
|
|
158
160
|
{ id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action", shortcut: formatShortcut(getBinding("open-git-graph")) },
|
|
161
|
+
{ id: "browser", label: "Open Browser", icon: Globe, action: openNewTab("browser", "Browser"), keywords: "web preview localhost iframe url", group: "action" },
|
|
159
162
|
{ id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
|
|
163
|
+
{ id: "voice-input", label: "Voice Input", icon: Mic, action: () => { window.dispatchEvent(new CustomEvent("toggle-voice-input")); onClose(); }, keywords: "speech microphone dictate voice", group: "action", shortcut: formatShortcut(getBinding("voice-input")) },
|
|
160
164
|
{ id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action", shortcut: formatShortcut(getBinding("open-git-status")) },
|
|
161
165
|
{
|
|
162
166
|
id: "settings", label: "Settings", icon: Settings,
|
|
@@ -23,6 +23,7 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
23
23
|
"git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
|
|
24
24
|
"git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
|
|
25
25
|
settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
|
|
26
|
+
browser: lazy(() => import("@/components/browser/browser-tab").then((m) => ({ default: m.BrowserTab }))),
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
interface EditorPanelProps {
|
|
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|
|
2
2
|
import {
|
|
3
3
|
Terminal, MessageSquare, GitBranch, Database,
|
|
4
4
|
FileDiff, FileCode, Settings, Menu, X, ArrowLeft, ArrowRight, SplitSquareVertical, MoveVertical, Layers, Plus,
|
|
5
|
-
ChevronLeft, ChevronRight,
|
|
5
|
+
ChevronLeft, ChevronRight, Globe,
|
|
6
6
|
} from "lucide-react";
|
|
7
7
|
import { usePanelStore } from "@/stores/panel-store";
|
|
8
8
|
import { useProjectStore, resolveOrder } from "@/stores/project-store";
|
|
@@ -25,7 +25,7 @@ const NEW_TAB_LABELS: Partial<Record<TabType, string>> = Object.fromEntries(NEW_
|
|
|
25
25
|
|
|
26
26
|
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
27
27
|
terminal: Terminal, chat: MessageSquare, editor: FileCode, database: Database, sqlite: Database, postgres: Database,
|
|
28
|
-
"git-graph": GitBranch, "git-diff": FileDiff, settings: Settings,
|
|
28
|
+
"git-graph": GitBranch, "git-diff": FileDiff, settings: Settings, browser: Globe,
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
interface MobileNavProps { onMenuPress: () => void; onProjectsPress: () => void; }
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
Database,
|
|
11
11
|
ChevronLeft,
|
|
12
12
|
ChevronRight,
|
|
13
|
+
Globe,
|
|
13
14
|
} from "lucide-react";
|
|
14
15
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
15
16
|
import { usePanelStore } from "@/stores/panel-store";
|
|
@@ -33,6 +34,7 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
|
33
34
|
"git-graph": GitBranch,
|
|
34
35
|
"git-diff": FileDiff,
|
|
35
36
|
settings: Settings,
|
|
37
|
+
browser: Globe,
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
interface TabBarProps {
|
|
@@ -48,6 +48,11 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
48
48
|
default: m.SettingsTab,
|
|
49
49
|
})),
|
|
50
50
|
),
|
|
51
|
+
browser: lazy(() =>
|
|
52
|
+
import("@/components/browser/browser-tab").then((m) => ({
|
|
53
|
+
default: m.BrowserTab,
|
|
54
|
+
})),
|
|
55
|
+
),
|
|
51
56
|
};
|
|
52
57
|
|
|
53
58
|
function LoadingFallback() {
|
|
@@ -124,6 +124,13 @@ export function useGlobalKeybindings() {
|
|
|
124
124
|
return;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Toggle voice input in chat
|
|
128
|
+
if (match(e, "voice-input")) {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
window.dispatchEvent(new CustomEvent("toggle-voice-input"));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
127
134
|
// Open search (sidebar)
|
|
128
135
|
if (match(e, "open-search")) {
|
|
129
136
|
e.preventDefault();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useState, useRef, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
// Extend Window for webkit prefix
|
|
4
|
+
interface SpeechRecognitionEvent extends Event {
|
|
5
|
+
results: SpeechRecognitionResultList;
|
|
6
|
+
resultIndex: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type SpeechRecognitionInstance = {
|
|
10
|
+
lang: string;
|
|
11
|
+
continuous: boolean;
|
|
12
|
+
interimResults: boolean;
|
|
13
|
+
start(): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
abort(): void;
|
|
16
|
+
onresult: ((event: SpeechRecognitionEvent) => void) | null;
|
|
17
|
+
onend: (() => void) | null;
|
|
18
|
+
onerror: ((event: Event & { error: string }) => void) | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
|
|
22
|
+
|
|
23
|
+
function getSpeechRecognition(): SpeechRecognitionConstructor | null {
|
|
24
|
+
const w = window as unknown as {
|
|
25
|
+
SpeechRecognition?: SpeechRecognitionConstructor;
|
|
26
|
+
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
|
27
|
+
};
|
|
28
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useVoiceInput(options?: { lang?: string }) {
|
|
32
|
+
const [isListening, setIsListening] = useState(false);
|
|
33
|
+
const [interimText, setInterimText] = useState("");
|
|
34
|
+
const recognitionRef = useRef<SpeechRecognitionInstance | null>(null);
|
|
35
|
+
// Accumulate finalized text across multiple result events
|
|
36
|
+
const finalizedRef = useRef("");
|
|
37
|
+
|
|
38
|
+
const supported = typeof window !== "undefined" && getSpeechRecognition() !== null;
|
|
39
|
+
|
|
40
|
+
const start = useCallback(
|
|
41
|
+
(onResult: (text: string, isFinal: boolean) => void) => {
|
|
42
|
+
const SR = getSpeechRecognition();
|
|
43
|
+
if (!SR) return;
|
|
44
|
+
|
|
45
|
+
// Stop any existing session
|
|
46
|
+
recognitionRef.current?.abort();
|
|
47
|
+
|
|
48
|
+
const recognition = new SR();
|
|
49
|
+
recognition.lang = options?.lang ?? "vi-VN";
|
|
50
|
+
recognition.continuous = true;
|
|
51
|
+
recognition.interimResults = true;
|
|
52
|
+
|
|
53
|
+
finalizedRef.current = "";
|
|
54
|
+
|
|
55
|
+
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
|
56
|
+
let interim = "";
|
|
57
|
+
let newFinalized = "";
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < event.results.length; i++) {
|
|
60
|
+
const result = event.results[i]!;
|
|
61
|
+
if (result.isFinal) {
|
|
62
|
+
newFinalized += result[0]!.transcript;
|
|
63
|
+
} else {
|
|
64
|
+
interim += result[0]!.transcript;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Update finalized accumulator
|
|
69
|
+
if (newFinalized) {
|
|
70
|
+
finalizedRef.current = newFinalized;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const fullText = (finalizedRef.current + " " + interim).trim();
|
|
74
|
+
setInterimText(interim);
|
|
75
|
+
onResult(fullText, interim.length === 0 && finalizedRef.current.length > 0);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
recognition.onend = () => {
|
|
79
|
+
setIsListening(false);
|
|
80
|
+
setInterimText("");
|
|
81
|
+
// Deliver final text if any
|
|
82
|
+
if (finalizedRef.current) {
|
|
83
|
+
onResult(finalizedRef.current.trim(), true);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
recognition.onerror = (event) => {
|
|
88
|
+
// "no-speech" and "aborted" are expected, not real errors
|
|
89
|
+
if (event.error !== "no-speech" && event.error !== "aborted") {
|
|
90
|
+
console.warn("[voice-input] error:", event.error);
|
|
91
|
+
}
|
|
92
|
+
setIsListening(false);
|
|
93
|
+
setInterimText("");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
recognitionRef.current = recognition;
|
|
97
|
+
recognition.start();
|
|
98
|
+
setIsListening(true);
|
|
99
|
+
},
|
|
100
|
+
[options?.lang],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const stop = useCallback(() => {
|
|
104
|
+
recognitionRef.current?.stop();
|
|
105
|
+
recognitionRef.current = null;
|
|
106
|
+
setIsListening(false);
|
|
107
|
+
setInterimText("");
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
return { isListening, interimText, start, stop, supported };
|
|
111
|
+
}
|
|
@@ -36,6 +36,7 @@ export const KEY_ACTIONS: KeyAction[] = [
|
|
|
36
36
|
{ id: "open-git-graph", label: "Git Graph", category: "tabs", defaultKey: "Mod+G" },
|
|
37
37
|
{ id: "open-git-status", label: "Git Status (sidebar)", category: "tabs", defaultKey: "Mod+Shift+E" },
|
|
38
38
|
{ id: "open-search", label: "Search Files (sidebar)", category: "tabs", defaultKey: "Mod+Shift+F" },
|
|
39
|
+
{ id: "voice-input", label: "Voice Input", category: "general", defaultKey: "Mod+Shift+V", note: "Toggle speech-to-text in chat" },
|
|
39
40
|
// Projects — Mod+1..9
|
|
40
41
|
...Array.from({ length: 9 }, (_, i) => ({
|
|
41
42
|
id: `switch-project-${i + 1}`,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import"./chunk-XZSTWKYB-BYxFzZwS.js";import{n as e}from"./chunk-R5LLSJPH-euR2RxLN.js";export{e as createArchitectureServices};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{it as e,rt as t}from"./chunk-7R4GIKGN-DXaGAn_K.js";var n=(n,r)=>e.lang.round(t.parse(n)[r]);export{n as t};
|