clawdex-mobile 2.0.1 → 3.0.0
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/.github/workflows/pages.yml +41 -0
- package/AGENTS.md +263 -110
- package/README.md +1 -1
- package/apps/mobile/.env.example +2 -2
- package/apps/mobile/App.tsx +175 -14
- package/apps/mobile/app.json +27 -9
- package/apps/mobile/eas.json +14 -4
- package/apps/mobile/package.json +13 -13
- package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
- package/apps/mobile/src/api/__tests__/client.test.ts +579 -6
- package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
- package/apps/mobile/src/api/account.ts +47 -0
- package/apps/mobile/src/api/chatMapping.ts +435 -18
- package/apps/mobile/src/api/client.ts +296 -36
- package/apps/mobile/src/api/rateLimits.ts +143 -0
- package/apps/mobile/src/api/types.ts +106 -0
- package/apps/mobile/src/api/ws.ts +10 -1
- package/apps/mobile/src/components/ChatHeader.tsx +12 -12
- package/apps/mobile/src/components/ChatInput.tsx +154 -88
- package/apps/mobile/src/components/ChatMessage.tsx +548 -93
- package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
- package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
- package/apps/mobile/src/components/ToolBlock.tsx +17 -15
- package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
- package/apps/mobile/src/components/WorkspacePickerModal.tsx +572 -0
- package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
- package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
- package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
- package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
- package/apps/mobile/src/components/chat-input-layout.ts +59 -0
- package/apps/mobile/src/components/chatImageSource.ts +86 -0
- package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
- package/apps/mobile/src/components/voiceWaveform.ts +46 -0
- package/apps/mobile/src/config.ts +9 -2
- package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
- package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
- package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
- package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
- package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
- package/apps/mobile/src/navigation/drawerChats.ts +9 -0
- package/apps/mobile/src/screens/GitScreen.tsx +2 -0
- package/apps/mobile/src/screens/MainScreen.tsx +4244 -1237
- package/apps/mobile/src/screens/OnboardingScreen.tsx +2 -0
- package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
- package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
- package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
- package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
- package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
- package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
- package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
- package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
- package/apps/mobile/src/screens/agentThreads.ts +167 -0
- package/apps/mobile/src/screens/planCardState.ts +40 -0
- package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
- package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
- package/apps/mobile/src/theme.ts +6 -12
- package/docs/codex-app-server-cli-gap-tracker.md +14 -5
- package/docs/privacy-policy.md +54 -0
- package/docs/setup-and-operations.md +4 -3
- package/docs/terms-of-service.md +33 -0
- package/package.json +3 -3
- package/services/mac-bridge/package.json +6 -6
- package/services/rust-bridge/Cargo.lock +56 -47
- package/services/rust-bridge/Cargo.toml +1 -1
- package/services/rust-bridge/package.json +1 -1
- package/services/rust-bridge/src/main.rs +507 -9
- package/site/index.html +54 -0
- package/site/privacy/index.html +80 -0
- package/site/styles.css +135 -0
- package/site/support/index.html +51 -0
- package/site/terms/index.html +68 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildComposerUsageLimitBadges,
|
|
3
|
+
formatComposerUsageLimitLabel,
|
|
4
|
+
} from '../usageLimitBadges';
|
|
5
|
+
|
|
6
|
+
describe('composerUsageLimits', () => {
|
|
7
|
+
it('maps primary and secondary windows into remaining-percentage badges', () => {
|
|
8
|
+
const badges = buildComposerUsageLimitBadges({
|
|
9
|
+
limitId: 'codex',
|
|
10
|
+
limitName: 'Codex',
|
|
11
|
+
planType: 'plus',
|
|
12
|
+
credits: null,
|
|
13
|
+
primary: {
|
|
14
|
+
usedPercent: 34,
|
|
15
|
+
windowDurationMins: 300,
|
|
16
|
+
resetsAt: 1_700_000_000,
|
|
17
|
+
},
|
|
18
|
+
secondary: {
|
|
19
|
+
usedPercent: 79,
|
|
20
|
+
windowDurationMins: 10_080,
|
|
21
|
+
resetsAt: 1_700_000_100,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(badges).toEqual([
|
|
26
|
+
{
|
|
27
|
+
id: 'primary',
|
|
28
|
+
label: '5h',
|
|
29
|
+
remainingPercent: 66,
|
|
30
|
+
tone: 'neutral',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'secondary',
|
|
34
|
+
label: 'weekly',
|
|
35
|
+
remainingPercent: 21,
|
|
36
|
+
tone: 'warning',
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('clamps remaining percent and escalates critical limits', () => {
|
|
42
|
+
const badges = buildComposerUsageLimitBadges({
|
|
43
|
+
limitId: 'codex',
|
|
44
|
+
limitName: null,
|
|
45
|
+
planType: 'pro',
|
|
46
|
+
credits: null,
|
|
47
|
+
primary: {
|
|
48
|
+
usedPercent: 140,
|
|
49
|
+
windowDurationMins: 60,
|
|
50
|
+
resetsAt: null,
|
|
51
|
+
},
|
|
52
|
+
secondary: {
|
|
53
|
+
usedPercent: 89.6,
|
|
54
|
+
windowDurationMins: 1_440,
|
|
55
|
+
resetsAt: null,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(badges).toEqual([
|
|
60
|
+
{
|
|
61
|
+
id: 'primary',
|
|
62
|
+
label: '1h',
|
|
63
|
+
remainingPercent: 0,
|
|
64
|
+
tone: 'critical',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'secondary',
|
|
68
|
+
label: '1d',
|
|
69
|
+
remainingPercent: 10,
|
|
70
|
+
tone: 'critical',
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('omits missing windows', () => {
|
|
76
|
+
const badges = buildComposerUsageLimitBadges({
|
|
77
|
+
limitId: 'codex',
|
|
78
|
+
limitName: null,
|
|
79
|
+
planType: 'team',
|
|
80
|
+
credits: null,
|
|
81
|
+
primary: null,
|
|
82
|
+
secondary: {
|
|
83
|
+
usedPercent: 45,
|
|
84
|
+
windowDurationMins: 120,
|
|
85
|
+
resetsAt: null,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(badges).toEqual([
|
|
90
|
+
{
|
|
91
|
+
id: 'secondary',
|
|
92
|
+
label: '2h',
|
|
93
|
+
remainingPercent: 55,
|
|
94
|
+
tone: 'neutral',
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('formats generic duration labels', () => {
|
|
100
|
+
expect(formatComposerUsageLimitLabel(45)).toBe('45m');
|
|
101
|
+
expect(formatComposerUsageLimitLabel(720)).toBe('12h');
|
|
102
|
+
expect(formatComposerUsageLimitLabel(2_880)).toBe('2d');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('falls back to 5h and weekly for the default codex pair when duration is omitted', () => {
|
|
106
|
+
const badges = buildComposerUsageLimitBadges({
|
|
107
|
+
limitId: 'codex',
|
|
108
|
+
limitName: 'Codex',
|
|
109
|
+
planType: 'plus',
|
|
110
|
+
credits: null,
|
|
111
|
+
primary: {
|
|
112
|
+
usedPercent: 31,
|
|
113
|
+
windowDurationMins: null,
|
|
114
|
+
resetsAt: 1_700_000_000,
|
|
115
|
+
},
|
|
116
|
+
secondary: {
|
|
117
|
+
usedPercent: 82,
|
|
118
|
+
windowDurationMins: null,
|
|
119
|
+
resetsAt: 1_700_000_100,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(badges).toEqual([
|
|
124
|
+
{
|
|
125
|
+
id: 'primary',
|
|
126
|
+
label: '5h',
|
|
127
|
+
remainingPercent: 69,
|
|
128
|
+
tone: 'neutral',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'secondary',
|
|
132
|
+
label: 'weekly',
|
|
133
|
+
remainingPercent: 18,
|
|
134
|
+
tone: 'warning',
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendVoiceWaveformSample,
|
|
3
|
+
fallbackVoiceWaveformLevel,
|
|
4
|
+
formatVoiceRecordingDuration,
|
|
5
|
+
normalizeVoiceMetering,
|
|
6
|
+
} from '../voiceWaveform';
|
|
7
|
+
|
|
8
|
+
describe('voiceWaveform', () => {
|
|
9
|
+
it('formats recording duration as mm:ss', () => {
|
|
10
|
+
expect(formatVoiceRecordingDuration(0)).toBe('00:00');
|
|
11
|
+
expect(formatVoiceRecordingDuration(9_000)).toBe('00:09');
|
|
12
|
+
expect(formatVoiceRecordingDuration(65_000)).toBe('01:05');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('normalizes metering into a bounded waveform level', () => {
|
|
16
|
+
expect(normalizeVoiceMetering(undefined)).toBe(0);
|
|
17
|
+
expect(normalizeVoiceMetering(-160)).toBe(0);
|
|
18
|
+
expect(normalizeVoiceMetering(5)).toBe(1);
|
|
19
|
+
expect(normalizeVoiceMetering(-5)).toBeGreaterThan(normalizeVoiceMetering(-35));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('keeps only the newest waveform samples', () => {
|
|
23
|
+
expect(appendVoiceWaveformSample([0.1, 0.2], 0.3, 4)).toEqual([0.1, 0.2, 0.3]);
|
|
24
|
+
expect(appendVoiceWaveformSample([0.1, 0.2, 0.3], 0.4, 3)).toEqual([0.2, 0.3, 0.4]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('provides a subtle fallback waveform level when metering is unavailable', () => {
|
|
28
|
+
expect(fallbackVoiceWaveformLevel(0)).toBeGreaterThan(0);
|
|
29
|
+
expect(fallbackVoiceWaveformLevel(10)).toBeLessThanOrEqual(1);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spacing } from '../theme';
|
|
2
|
+
|
|
3
|
+
const IOS_HOME_INDICATOR_THRESHOLD = 20;
|
|
4
|
+
const ANDROID_NAV_BUTTONS_THRESHOLD = 16;
|
|
5
|
+
|
|
6
|
+
export interface ComposerBottomSpacing {
|
|
7
|
+
baseBottomPadding: number;
|
|
8
|
+
extraBottomInset: number;
|
|
9
|
+
totalBottomPadding: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveComposerBottomSpacing(
|
|
13
|
+
platform: string,
|
|
14
|
+
safeAreaBottomInset: number,
|
|
15
|
+
keyboardVisible: boolean
|
|
16
|
+
): ComposerBottomSpacing {
|
|
17
|
+
const normalizedInset = Number.isFinite(safeAreaBottomInset)
|
|
18
|
+
? Math.max(0, safeAreaBottomInset)
|
|
19
|
+
: 0;
|
|
20
|
+
|
|
21
|
+
const baseBottomPadding = resolveBaseBottomPadding(platform, keyboardVisible);
|
|
22
|
+
const extraBottomInset = keyboardVisible
|
|
23
|
+
? 0
|
|
24
|
+
: resolveRestingBottomInset(platform, normalizedInset);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
baseBottomPadding,
|
|
28
|
+
extraBottomInset,
|
|
29
|
+
totalBottomPadding: baseBottomPadding + extraBottomInset,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveBaseBottomPadding(platform: string, keyboardVisible: boolean): number {
|
|
34
|
+
if (platform === 'ios') {
|
|
35
|
+
return keyboardVisible ? 2 : spacing.xs + 2;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (platform === 'android') {
|
|
39
|
+
return keyboardVisible ? 0 : spacing.sm;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return keyboardVisible ? 0 : spacing.sm;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveRestingBottomInset(platform: string, safeAreaBottomInset: number): number {
|
|
46
|
+
if (platform === 'ios') {
|
|
47
|
+
return safeAreaBottomInset >= IOS_HOME_INDICATOR_THRESHOLD ? spacing.sm : 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (platform === 'android') {
|
|
51
|
+
if (safeAreaBottomInset >= ANDROID_NAV_BUTTONS_THRESHOLD) {
|
|
52
|
+
return spacing.sm;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return safeAreaBottomInset > 0 ? 2 : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return safeAreaBottomInset;
|
|
59
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
type RemoteImageSource = {
|
|
2
|
+
uri: string;
|
|
3
|
+
headers?: Record<string, string>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const REMOTE_SCHEME_PATTERN =
|
|
7
|
+
/^(?:https?:\/\/|data:image\/|content:\/\/|assets-library:\/\/|ph:\/\/|blob:)/i;
|
|
8
|
+
const FILE_SCHEME_PATTERN = /^file:\/\//i;
|
|
9
|
+
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
|
10
|
+
|
|
11
|
+
export function toMarkdownImageSource(
|
|
12
|
+
rawSource: string,
|
|
13
|
+
bridgeUrl: string | null | undefined,
|
|
14
|
+
bridgeToken: string | null | undefined
|
|
15
|
+
): RemoteImageSource | null {
|
|
16
|
+
const normalizedSource = rawSource.trim();
|
|
17
|
+
if (!normalizedSource) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (FILE_SCHEME_PATTERN.test(normalizedSource)) {
|
|
22
|
+
const withoutScheme = normalizedSource.replace(FILE_SCHEME_PATTERN, '');
|
|
23
|
+
return toBridgeImageSource(withoutScheme, bridgeUrl, bridgeToken);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (REMOTE_SCHEME_PATTERN.test(normalizedSource)) {
|
|
27
|
+
return { uri: normalizedSource };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (normalizedSource.startsWith('/') || WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalizedSource)) {
|
|
31
|
+
return toBridgeImageSource(normalizedSource, bridgeUrl, bridgeToken);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toBridgeImageSource(
|
|
38
|
+
rawPath: string,
|
|
39
|
+
bridgeUrl: string | null | undefined,
|
|
40
|
+
bridgeToken: string | null | undefined
|
|
41
|
+
): RemoteImageSource | null {
|
|
42
|
+
const normalizedBridgeUrl = bridgeUrl?.trim();
|
|
43
|
+
if (!normalizedBridgeUrl) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const normalizedPath = normalizeLocalPath(rawPath);
|
|
48
|
+
if (!normalizedPath) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const uri = `${normalizedBridgeUrl.replace(/\/$/, '')}/local-image?path=${encodeURIComponent(
|
|
53
|
+
normalizedPath
|
|
54
|
+
)}`;
|
|
55
|
+
const token = bridgeToken?.trim();
|
|
56
|
+
|
|
57
|
+
return token
|
|
58
|
+
? {
|
|
59
|
+
uri,
|
|
60
|
+
headers: {
|
|
61
|
+
Authorization: `Bearer ${token}`,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
: { uri };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeLocalPath(rawPath: string): string | null {
|
|
68
|
+
let normalizedPath = rawPath.trim();
|
|
69
|
+
if (!normalizedPath) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
normalizedPath = normalizedPath.replace(/\\/g, '/');
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
normalizedPath = decodeURI(normalizedPath);
|
|
77
|
+
} catch {
|
|
78
|
+
// Keep original path when URI decoding fails.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalizedPath) && !normalizedPath.startsWith('/')) {
|
|
82
|
+
normalizedPath = `/${normalizedPath}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return normalizedPath;
|
|
86
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { AccountRateLimitSnapshot, AccountRateLimitWindow } from '../api/types';
|
|
2
|
+
|
|
3
|
+
export type ComposerUsageLimitTone = 'neutral' | 'warning' | 'critical';
|
|
4
|
+
|
|
5
|
+
export interface ComposerUsageLimitBadgeModel {
|
|
6
|
+
id: 'primary' | 'secondary';
|
|
7
|
+
label: string;
|
|
8
|
+
remainingPercent: number;
|
|
9
|
+
tone: ComposerUsageLimitTone;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildComposerUsageLimitBadges(
|
|
13
|
+
snapshot: AccountRateLimitSnapshot | null
|
|
14
|
+
): ComposerUsageLimitBadgeModel[] {
|
|
15
|
+
if (!snapshot) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const badges: ComposerUsageLimitBadgeModel[] = [];
|
|
20
|
+
const primary = toComposerUsageLimitBadge('primary', snapshot.primary, snapshot);
|
|
21
|
+
if (primary) {
|
|
22
|
+
badges.push(primary);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const secondary = toComposerUsageLimitBadge('secondary', snapshot.secondary, snapshot);
|
|
26
|
+
if (secondary) {
|
|
27
|
+
badges.push(secondary);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return badges;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatComposerUsageLimitLabel(windowDurationMins: number | null): string {
|
|
34
|
+
if (windowDurationMins === null || windowDurationMins <= 0) {
|
|
35
|
+
return 'limit';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (windowDurationMins === 300) {
|
|
39
|
+
return '5h';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (windowDurationMins === 10_080) {
|
|
43
|
+
return 'weekly';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (windowDurationMins < 60) {
|
|
47
|
+
return `${String(windowDurationMins)}m`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (windowDurationMins < 1_440) {
|
|
51
|
+
return `${String(Math.round(windowDurationMins / 60))}h`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `${String(Math.round(windowDurationMins / 1_440))}d`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toComposerUsageLimitBadge(
|
|
58
|
+
id: ComposerUsageLimitBadgeModel['id'],
|
|
59
|
+
window: AccountRateLimitWindow | null,
|
|
60
|
+
snapshot: AccountRateLimitSnapshot
|
|
61
|
+
): ComposerUsageLimitBadgeModel | null {
|
|
62
|
+
if (!window) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const remainingPercent = clampPercent(100 - window.usedPercent);
|
|
67
|
+
return {
|
|
68
|
+
id,
|
|
69
|
+
label: resolveComposerUsageLimitLabel(id, window.windowDurationMins, snapshot),
|
|
70
|
+
remainingPercent,
|
|
71
|
+
tone:
|
|
72
|
+
remainingPercent <= 10
|
|
73
|
+
? 'critical'
|
|
74
|
+
: remainingPercent <= 25
|
|
75
|
+
? 'warning'
|
|
76
|
+
: 'neutral',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveComposerUsageLimitLabel(
|
|
81
|
+
id: ComposerUsageLimitBadgeModel['id'],
|
|
82
|
+
windowDurationMins: number | null,
|
|
83
|
+
snapshot: AccountRateLimitSnapshot
|
|
84
|
+
): string {
|
|
85
|
+
const explicitLabel = formatComposerUsageLimitLabel(windowDurationMins);
|
|
86
|
+
if (explicitLabel !== 'limit') {
|
|
87
|
+
return explicitLabel;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const normalizedLimitId = snapshot.limitId?.trim().toLowerCase() ?? null;
|
|
91
|
+
const hasPrimary = Boolean(snapshot.primary);
|
|
92
|
+
const hasSecondary = Boolean(snapshot.secondary);
|
|
93
|
+
const looksLikeDefaultCodexPair =
|
|
94
|
+
hasPrimary && hasSecondary && (!normalizedLimitId || normalizedLimitId === 'codex');
|
|
95
|
+
|
|
96
|
+
if (looksLikeDefaultCodexPair) {
|
|
97
|
+
return id === 'primary' ? '5h' : 'weekly';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return 'limit';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clampPercent(value: number): number {
|
|
104
|
+
if (!Number.isFinite(value)) {
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
109
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const VOICE_WAVEFORM_BAR_COUNT = 28;
|
|
2
|
+
export const VOICE_WAVEFORM_SAMPLE_INTERVAL_MS = 80;
|
|
3
|
+
|
|
4
|
+
const METERING_FLOOR_DB = -55;
|
|
5
|
+
|
|
6
|
+
export function createVoiceWaveformSeed(barCount: number): number[] {
|
|
7
|
+
return Array.from({ length: Math.max(1, barCount) }, () => 0);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function appendVoiceWaveformSample(
|
|
11
|
+
previousSamples: number[],
|
|
12
|
+
nextSample: number,
|
|
13
|
+
maxBars: number
|
|
14
|
+
): number[] {
|
|
15
|
+
const trimmedSamples = previousSamples.slice(-(Math.max(1, maxBars) - 1));
|
|
16
|
+
return [...trimmedSamples, clampWaveformLevel(nextSample)];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizeVoiceMetering(metering: number | null | undefined): number {
|
|
20
|
+
if (typeof metering !== 'number' || Number.isNaN(metering)) {
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const clampedMetering = Math.max(METERING_FLOOR_DB, Math.min(0, metering));
|
|
25
|
+
const normalizedLevel = (clampedMetering - METERING_FLOOR_DB) / Math.abs(METERING_FLOOR_DB);
|
|
26
|
+
return clampWaveformLevel(Math.pow(normalizedLevel, 0.65));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fallbackVoiceWaveformLevel(step: number): number {
|
|
30
|
+
const oscillation = Math.abs(Math.sin(step / 2.35));
|
|
31
|
+
return clampWaveformLevel(0.12 + oscillation * 0.24);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatVoiceRecordingDuration(durationMillis: number): string {
|
|
35
|
+
const totalSeconds = Math.max(0, Math.floor(durationMillis / 1_000));
|
|
36
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
37
|
+
const seconds = totalSeconds % 60;
|
|
38
|
+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clampWaveformLevel(level: number): number {
|
|
42
|
+
if (!Number.isFinite(level)) {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
return Math.max(0, Math.min(1, level));
|
|
46
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { isInsecureRemoteUrl, normalizeBridgeUrlInput } from './bridgeUrl';
|
|
2
2
|
|
|
3
|
+
const defaultPrivacyPolicyUrl =
|
|
4
|
+
'https://mohit-patil.github.io/clawdex-mobile/privacy/';
|
|
5
|
+
const defaultTermsOfServiceUrl =
|
|
6
|
+
'https://mohit-patil.github.io/clawdex-mobile/terms/';
|
|
7
|
+
|
|
3
8
|
const legacyHostBridgeUrl = normalizeBridgeUrlInput(
|
|
4
9
|
process.env.EXPO_PUBLIC_HOST_BRIDGE_URL ??
|
|
5
10
|
process.env.EXPO_PUBLIC_MAC_BRIDGE_URL ??
|
|
@@ -15,8 +20,10 @@ const allowWsQueryTokenAuth =
|
|
|
15
20
|
const allowInsecureRemoteBridge =
|
|
16
21
|
process.env.EXPO_PUBLIC_ALLOW_INSECURE_REMOTE_BRIDGE?.trim().toLowerCase() ===
|
|
17
22
|
'true';
|
|
18
|
-
const privacyPolicyUrl =
|
|
19
|
-
|
|
23
|
+
const privacyPolicyUrl =
|
|
24
|
+
process.env.EXPO_PUBLIC_PRIVACY_POLICY_URL?.trim() || defaultPrivacyPolicyUrl;
|
|
25
|
+
const termsOfServiceUrl =
|
|
26
|
+
process.env.EXPO_PUBLIC_TERMS_OF_SERVICE_URL?.trim() || defaultTermsOfServiceUrl;
|
|
20
27
|
const externalStatusFullSyncDebounceMs = parseNonNegativeIntEnv(
|
|
21
28
|
process.env.EXPO_PUBLIC_EXTERNAL_STATUS_FULL_SYNC_DEBOUNCE_MS,
|
|
22
29
|
450
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
requestRecordingPermissionsAsync,
|
|
7
7
|
setAudioModeAsync,
|
|
8
8
|
useAudioRecorder,
|
|
9
|
+
useAudioRecorderState,
|
|
9
10
|
} from 'expo-audio';
|
|
10
11
|
import * as FileSystem from 'expo-file-system/legacy';
|
|
11
12
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
@@ -30,9 +31,10 @@ interface UseVoiceRecorderOptions {
|
|
|
30
31
|
const MIN_RECORDING_DURATION_MS = 1_000;
|
|
31
32
|
const MAX_RECORDING_FILE_BYTES = 20 * 1024 * 1024;
|
|
32
33
|
const MAX_RECORDING_FILE_MB = MAX_RECORDING_FILE_BYTES / (1024 * 1024);
|
|
34
|
+
const RECORDER_STATE_POLL_INTERVAL_MS = 80;
|
|
33
35
|
|
|
34
36
|
const RECORDING_OPTIONS: RecordingOptions = {
|
|
35
|
-
isMeteringEnabled:
|
|
37
|
+
isMeteringEnabled: true,
|
|
36
38
|
extension: '.m4a',
|
|
37
39
|
sampleRate: 16_000,
|
|
38
40
|
numberOfChannels: 1,
|
|
@@ -119,6 +121,7 @@ export function useVoiceRecorder({
|
|
|
119
121
|
}: UseVoiceRecorderOptions) {
|
|
120
122
|
const [voiceState, setVoiceState] = useState<VoiceState>('idle');
|
|
121
123
|
const recorder = useAudioRecorder(RECORDING_OPTIONS);
|
|
124
|
+
const recorderState = useAudioRecorderState(recorder, RECORDER_STATE_POLL_INTERVAL_MS);
|
|
122
125
|
const startTimeRef = useRef<number>(0);
|
|
123
126
|
const recorderRef = useRef<AudioRecorder>(recorder);
|
|
124
127
|
recorderRef.current = recorder;
|
|
@@ -256,6 +259,10 @@ export function useVoiceRecorder({
|
|
|
256
259
|
|
|
257
260
|
return {
|
|
258
261
|
voiceState,
|
|
262
|
+
recordingDurationMillis:
|
|
263
|
+
voiceState === 'recording' ? recorderState.durationMillis : 0,
|
|
264
|
+
recordingMetering:
|
|
265
|
+
voiceState === 'recording' ? recorderState.metering ?? null : null,
|
|
259
266
|
startRecording,
|
|
260
267
|
stopRecordingAndTranscribe,
|
|
261
268
|
cancelRecording,
|